PUDL
GitHub v0.23.1

PUDL, the Pleasantly Usable Design Language

PUDL is a control grammar modelled on the conventions of desktop toolkits such as GTK: raised buttons, sunken inputs, segmented controls, a master-detail layout, filter chips, key/value tables, switches and server-rendered dialogs. Elevation signals interactivity, so raised means pressable, sunken means input and flat means display. Colour is never the only signal, and every meaningful state carries a glyph.

1. Tokens

A theme sets the tokens below for light and dark, and PUDL derives every other token from them. The default palette shown here is a neutral graphite, with a dark graphite topbar in both themes. A project may replace all of it. A change to the accent reaches links, primary buttons, focus rings, filter chips, accent badges, the current list row, selected table rows, a switch that is on and the active window.

--bgpage
--surfacecard / sidebar
--surface-altrecessed tint
--accentinteractivity
--warncaution
--dangerdestructive
--positiveshipped / Done
--borderhairline

2. Buttons

Every button is raised, because a button can be pressed, and there is no flat button. A disabled button is dimmed but stays raised, since it is still a button, only one that does nothing yet. A visible context has at most one .btn-primary; a view that seems to need two has a secondary or destructive action hiding in one of them.

A toggle button latches. With aria-pressed="true" it stays pressed in until pressed again, and the same attribute tells assistive technology that it is on. A primary button may latch too, such as a flash card's Reveal: it keeps its accent and its text, and loses its lift. Press these three.

3. Forms

A field that takes input is sunken. A read-only value is flat, with no field around it, so it never pretends to be editable. A switch is a raised thumb in a sunken track, and when it is on, the track fills with the accent and the thumb sits at the far end, so its position says so as well as its colour. An error leads with its warning glyph.

Text input
Read-only
Select
Textarea
Checkbox
Switch
Error
Destination cannot be empty

A form the server has refused comes back with each wrong field marked aria-invalid="true", drawn with a danger border and tied by aria-describedby to the error beneath it, which carries its glyph. A required field's label carries an asterisk drawn from the field's own required attribute. Help text sits under a field in muted type. Related choices, such as radio buttons, sit in a fieldset with a legend, and a file field's button is raised like any other.

In the currency of the receipt.

The amount must be a number greater than zero.

Account

4. Segmented control

A segmented control offers two to four choices in a sunken trough, with the chosen one raised in it: a small scale, kg or lbs, Read or Edit. Its choices are buttons marked with aria-pressed, or links to views marked with aria-current.

This one is live. It is a theme setting built on pudl-theme.js, which remembers light, dark or system, and follows the operating system's setting while System is chosen, even when it changes with the page open.

Theme

5. Section tabs

Section tabs are a notebook, after the widget of the same name in desktop toolkits. The bar is a recessed band. Each tab the reader can go to is a raised tab standing on the band, because it can be pressed. The tab for where the reader is, marked with aria-current, is flat, a little taller, and open at the bottom into the content below, so the page visibly hangs from it. A page inside a section marks that section's tab with aria-current="true", and the section's own page with aria-current="page". The current tab takes --section-current-bg, which a page whose content sits on the page background sets to var(--bg).

The content of the Articles section sits here, joined to its tab.

6. Badges and chips

Three small labels each look like what they are. A badge states an entity's state, done or overdue, and always leads with its glyph as well as its colour. A chip names an attribute the thing carries, such as a tag or a trip, and is a plain neutral label. A filter chip, shown with the master–detail layout below, is a filter in force: it is outlined, and its × is a small raised button that removes it.

ready active approaching overdue done manila-oct billable RRP trip manila-oct ×

7. Cards

A card groups what belongs to one thing on a surface lifted slightly off the page.

Hotel, Makati, three nights

Receipt attached; charged to the customer's billing account.

done manila-oct billable

8. Key/value table

A record's read-only fields render as a table of labels and values, not as input boxes that cannot be typed into. Flat means display, and the table says so honestly.

ExpenseHotel, Makati, three nights
Created2026-10-19 07:00 UTC
AmountPHP 21,600.00
AccountRRP billable

9. Data table

A data table lists many records, one per row. A column the reader can sort by has a raised header, because pressing it re-sorts, with a glyph showing the column's direction, or two small arrows while it is not the sort. The header is an ordinary link such as ?sort=amount&dir=desc, so sorting needs no script and a sorted list can be shared. Numbers align to the end. A selected row shows a checked box, a tint and an accent edge at its start. When the table is narrow, as on a phone, a table marked .stack shows each row as a small card of labelled values; make this window narrow to see it.

Expenses, Manila, October
Expense Date Account State Amount
Hotel, Makati, three nights RRP billable done 21,600.00
Airport transfer RRP billable receipt due 1,200.00
Team dinner Non-billable ready 6,800.00

A table with nothing in it says so in its own row, rather than showing an empty frame.

ExpenseDateAmount
No expenses match these filters.

10. Notices and toasts

A notice is a message that stays on the page until it is dealt with, and a toast is a passing confirmation that leaves by itself after a few seconds, waiting while the pointer or focus is on it. Both lead with the glyph of their kind and carry a coloured edge, so neither rests on colour alone, and screen readers announce a toast as it appears. An error the reader has to act on is always a notice, because a toast goes away. With pudl-toast.js, a script raises a toast with pudlToast(), and a server can render one into the page after a form redirects.

Receipts are due by Friday

Two expenses on this trip are still waiting for a receipt.

The October report was sent to accounts.

This expense is over the nightly hotel limit for Manila.

11. Tabs within a page

Tabs within a page switch between panels of one record in place, where section tabs go to other addresses. They look alike, because to the reader both are tabs. With pudl-tabs.js, a tab is chosen by pressing it or with the arrow keys once the tab list has focus, the tab list is a single stop in the Tab order, and the chosen panel's id becomes the address's fragment, so a link can open a particular panel and a reload keeps it. Without the script, every panel shows one after another.

MerchantMakati Garden Hotel
AmountPHP 21,600.00

receipt-makati-garden.pdf, 212 KB, attached 19 October.

Submitted 19 October by Paul; approved 21 October by accounts.

12. Empty and loading states

An empty state tells the reader there is nothing here yet and what to do about it. A loading state pairs a spinner with words, in an element with role="status", so it is announced and never rests on the animation alone. The icon buttons on this page, the window buttons and the filter's apply button show a tooltip with their name on hover or keyboard focus, through pudl-tooltip.js.

No expenses on this trip yet

Add one as you spend, and attach its receipt, so the report builds itself.

Loading expenses…

13. Pagination

A long list comes in pages, each an address such as ?page=3, so paging works without script and a page can be shared. The page links are raised; the current page is pressed in, because the reader is already there; and previous and next carry their glyphs and are disabled at either end.

14. Dialogs

A dialog is a native <dialog class="dialog"> that the server renders into the page, never the browser's own confirm(). A button opens it with command="show-modal", and the browser keeps focus inside it, closes it on Escape and draws the backdrop, which dims the page gently while the panel's shadow does the lifting. The panel is one step brighter than the page. It offers two actions, the safe one first and the committing one last, and a destructive commit carries its warning glyph. The optional pudl-dialog.js provides the command buttons in browsers that do not have them yet.

Discard unsaved changes?

You've edited this entry but haven't saved. Leaving now will lose those changes.

15. Master–detail layout

The master–detail layout serves any list beside one of its records. A toolbar runs across the top, the filters in force sit below it as filter chips, and under them the list and the record share the width. The record in view has its row marked, by aria-current on the row's link, with an accent edge on the side facing the record. The list is 260px wide to begin with; with pudl-md.js the reader can drag the divider, or focus it and use the arrow keys.

trip manila-oct × account billable × clear
Expenses

Hotel, Makati, three nights

Receipt attached; charged to the customer's billing account.

done manila-oct billable
Created2026-10-19 07:00 UTC
AmountPHP 21,600.00
AccountRRP billable

One pane at a time

When the layout is 640 px wide or less, on a phone or in a narrow window or panel, it shows either the list or one record, never both. Each is its own URL. The server marks the layout with data-md-pane="detail" when the URL names a record, and the record's pane then opens with a back link to the list, which keeps the filters and names the record's row as its fragment so that the list scrolls back to where the reader left it. The list's toolbar and filter chips belong to the list and step aside while a record is showing. The back link returns to the list the record lives in, and is not a breadcrumb trail. The two layouts below are the same markup at phone width, one on each pane.

Expenses

Hotel, Makati, three nights

Receipt attached; charged to the customer's billing account.

done manila-oct
Created2026-10-19 07:00 UTC
AmountPHP 21,600.00
AccountRRP billable

A menu button opens a panel of choices. The button is raised and carries a caret, so it reads as opening something rather than doing something, and it looks pressed while its panel is open. The panel is lifted like a dialog but has no backdrop, because it is not modal: Escape, a click outside it or a choice closes it. Places in a panel are list rows grouped under section labels, and actions are rows of their own below a rule, each with a glyph. The panel is an HTML popover, so all of that works without script. The optional pudl-menu.js opens the panel against its button, as a full-width sheet on a phone, lets the arrow keys move between rows, and makes a filter at the top of a panel narrow its rows as you type. An open menu is not part of the URL, because it is momentary; everything in it has an address of its own. The Trip and Account buttons in the master–detail toolbar above are menus too.

A launcher is a menu button at the start of the row that holds a window dock, with a panel that reaches everything a site offers, grouped by category, with a filter at the top. The windows demo below has one, and so does the article reader sample.

A menu can also be summoned from the keyboard. A panel carrying data-menu-key="/" opens when / is pressed anywhere outside a text field, with focus in its filter, so the reader can type part of a name and press Enter to go there. On this page, / opens the expenses launcher. If nothing matches and the filter sits in a form, Enter submits the form, so a server can take the typed text to a page of its own, which makes the menu a "go to" palette that still ends in an ordinary address. A panel with no button of its own opens near the top of the window, as a palette.

17. Floating windows

Records can open as windows floating above a list, from the optional pudl-windows.css and pudl-windows.js. Open a few of the expenses below, then drag a window by its title bar, resize it from any edge or corner, drag it against the left, right or top edge to snap it, and double-click its title bar to maximise it. The dock above the list has one tab per open window. Everything you do is written into this page's URL, so reloading puts every window back where it was, and Back undoes the last window you opened.

The frame around a window is a band of the raised tint between two hairlines, and the whole band is the resize border. The active window's title bar takes the accent. With the title bar focused, the arrow keys move the window, Shift with the arrow keys resizes it, and Enter maximises or restores it. Without script, each link opens the record's own page, and the server renders whichever windows the URL names where the URL puts them.

A window can have children, for content that belongs to a record, such as a receipt, a source listing or an image. Open the hotel expense and follow its receipt link. The receipt opens maximised above the expense, a row for it appears under the expense in the list, and Escape or the close button puts it away. A child has no tab in the dock, travels with its parent when the parent is minimised or brought forward, and closes when its parent closes. The list also marks the row of the window in front. The article reader sample puts all of this together as a reading site, with the article list in a sidebar and each article opening maximised.

The sample also shows three things for moving around and for interactive content. A "Next" link at the foot of an article opens the next one in its place, as a link does in a browser tab, and Back returns to the one before. One article holds an applet, a colour mixer, which runs from the same script in the window and in a page of its own, keeping its state in the address only when it owns the page. And scripts can drive the windows through pudlWindows, and hear each window close through the pudl:window-close event, instead of clicking PUDL's buttons.

A link followed from inside a window opens its window in the same state, so following links never overturns the arrangement: from a floating window, the new one floats a step down and to the right. And the sample's category tabs above the list change only the list. The optional pudl-regions.js swaps the parts of the page a navigation changes, marked with data-region, and leaves the windows as they were, scroll positions and running applets included.

18. Invariants

These hold everywhere PUDL is used, and a page that breaks one is not using PUDL as intended.