Ir para o conteúdo

Design

Esta página ainda não foi traduzida, por isso é mostrada em inglês.

What Postulo's interface is made of, and when to reach for each piece. This is for anybody changing it — a contributor, a plugin author drawing a settings page, or you, deciding whether a change belongs.

There is a gallery in the application itself: Server settings → Design. It shows every component on one page, in whichever theme you are reading it in, and it is walked by the browser test suite on purpose — a component seen only inside a feature is a component nobody reviews. Look there first; this page says why.

The design gallery: every component of the interface on one page.

The one rule

Conformance is a floor, not a design. Every page passes axe in both themes, reflows at 320 pixels, survives a text-spacing override, keeps its focus rings under Windows High Contrast and prints as ink on white. None of that makes an interface good to look at, and none of it was ever supposed to. But nothing below may be bought by giving any of it up.

Colour

Two scales and a set of semantic names.

Ink is the greys — a warm near-black rather than pure black, which is easier to read at length. Eleven steps, ink-50 to ink-950. Most of the interface is these.

Brand is a deep blue, ten steps. Sober enough for something you use while job hunting.

The semantic names are what a component asks for: background, foreground, card, primary, muted, border, input, ring and the rest. They are Basecoat's vocabulary, so a rule copied from its documentation compiles here and comes out in this palette. Prefer them in a component; reach for ink-500 in a page.

The palette is seven colours — grey, blue, amber, violet, teal, green, rose — each tuned to 3:1 against the card in both themes. They exist to tell one kind of thing from another, and they are spent in two places: the tone of a pill and the tone of a figure. An offer is green wherever an offer is drawn, a rejection rose, a conversation in progress the brand. Something that is only a count, or only a fact, has no tone.

Two rules about colour that are not negotiable:

  • Colour may never be the only thing saying something. A state marked by colour alone is invisible to somebody who does not tell those two shades apart. Give it a word, a shape or a weight as well.
  • A high-contrast theme throws backgrounds and shadows away. It keeps borders, text decorations and outlines. Anything carrying meaning in a background needs an answer there, which is why the two forced-colors blocks in the stylesheet are deliberately outside every layer.

Rounding

One token, --radius, and every step derived from it by arithmetic: rounded-sm, rounded-md, rounded-lg, rounded-xl. Change the base and the whole interface moves together.

Do not write a rounding that is not one of those. Before the token existed, .card chose one, .btn chose another, a field chose a third and the templates chose four more — not because anybody decided the corners should disagree, but because there was nothing to agree with.

Depth

Three planes, and three is enough:

Plane What is on it
page The background. Everything else is above it.
shadow-raised A card, a table, the masthead once you have scrolled under it.
shadow-floating A popover, a menu, a dialog — something over the page rather than in it.

Ask for a plane, never for a blur radius. shadow-lg in a template means somebody wrote shadow-lg, and a test now refuses it.

Both shadows are soft on purpose, and every raised surface also has a border. A shadow is discarded outright under forced colours, so it may say this is above that and may never be the only thing saying it.

Type

One family, and a handful of sizes used consistently:

  • a page title: <c-page-title>, with the sentence under it in its subtitle slot
  • a section heading: <c-section-title>, likewise; a heading that shares a row with the actions beside it is a bare <h2 class="font-medium">
  • ordinary text: the base size
  • quieter text — an explanation, an unfilled value: text-sm in ink-500
  • the smallest label: text-xs
  • anything you compare down a column: add tabular-nums

Appearance settings in the dark theme, where the theme, the density and the accent are chosen.

Density

Settings → Appearance offers comfortable and compact. Comfortable is the default and stays it: the roomier interface is the easiest to hit and to read, and it is what the conformance claim rests on. Compact is somebody asking for more rows on a screen.

Compact tightens the room around things — the padding inside a card and inside a table cell — and nothing else. It may not shrink a target below 24×24. The browser suite walks the entire interface a second time with compact on to hold that, because a preference that quietly cost a criterion would be the compromise this exists to avoid.

Buttons with an icon and words

Settings → Appearance → How buttons read chooses, for every button that has both an icon and words (the company's actions, the new-listing group, the calendar's period buttons, Import and Export), whether it shows both, only the icons — the words stay as the button's name and become its tooltip — or only the words. A button with words alone does not change. A group of buttons is icons only on a phone-width screen whatever is chosen, unless it is words only (#852). The markup is an icon followed by a <span class="btn-label">.

Forms

A form row is Basecoat's field: a <div class="field"> holding an ordinary <label> and an ordinary control, with nothing on either carrying a class. The stylesheet keys on the elements inside the wrapper -- the label's weight, the box, a <p> as the help, a role="alert" as the errors -- and data-invalid on the row turns the label red with them. <c-field :field="form.name" /> emits that shape, and it is what the 117 rows a single field can draw use; a row it cannot draw writes the same three lines out.

A checkbox lies the other way, data-orientation="horizontal": the box first, then a <section> with the label beside it and the help under the label. <c-field> knows which shape a field wants. A hand-written checkbox or radio says class="input" to be drawn by the stylesheet, and a loop over a choice widget asks for the same with form.theme|widgets_classed:"input". The target that has to reach 24 pixels is the label that switches them, as it always was. A group of choices (a set of checkboxes or radios) is not a row with a label: <c-field> draws it as a <fieldset> with a <legend>, the help and the errors tied to the fieldset, even when it holds no choice yet.

A control with something attached to it -- the flag beside a country chooser -- is an input-group: the group carries the box and the ring, and the control inside it has none of its own.

Two controls may share one group. The telephone field is the flag, the country chooser and the number in one input-group with role="group" and a name; each control inside it has a name of its own, and the visible label points at the box. It wraps where there is no room, the box dropping to a line of its own inside the same frame, and the control that has the focus is outlined inside the group, because the ring round two controls would not say which. Such a group is not a size container: Basecoat makes a role="group" in a field one, and this one undoes it. The box is written left to right whatever the page's direction, and on a right-to-left page its text keeps to the side the page's boxes start at.

A telephone row is .phone-row: one grid holding the kind, the name and the group. From 768 pixels the group sits beside the kind; in a row that shows its name it takes the line under the kind and the name. The stylesheet follows the kind's select, as data-if-other does for the name box, so the arrangement needs no script.

The same group holds an icon where the choice has one: the service of a web link. An <option> holds words and nothing else, so every icon the choice can draw is in the group, each in a <span data-service-icon> and all but the chosen one hidden; the server draws the stored one and app.js shows the one named by the chosen option's data-icon. The row is right with scripts off, and stops following the select until it is saved. On a row being added the same script shows the service the address's host suggests and marks the select data-service-guessed; as the form is sent, a select still carrying that mark is put back to nothing chosen, so what a script chose is never posted and saving decides, as it does with scripts off.

A box that only means something for one choice is hidden by the stylesheet and not by a script: the group is data-if-other and the box data-name-if-other, shown while the select's other option is chosen or the box already holds something. A web link's row is a service, that name, the address and its controls, in that order in the source, on one wrapping line from sm and a column below it.

The control's own box is on the element, not on a class: an <input> in a toolbar and one on a form are the same box. The four field-* classes that used to say it are gone, and tests/test_template_lint.py refuses a template that names one.

Fields that share a line and must stay level are an address-line: a grid whose children are each a field, one under another below 640 pixels and side by side from there up, where each field is a subgrid of the line's three rows — label, control, what is said about it. So a label that wraps, or an error under one box, moves every box of the line together; a flex row can keep the tops level or the bottoms and each is wrong for one of those. data-narrow gives one column a short width by its place on the line ("1", "2", "3", 10rem), or "menu" for a line that leads with a select, which is then as wide as its longest option. The postal address rows are the only user today.

A field's help is one of two things (#302). A sentence that says what the field is for is a tooltip: <c-field :field="form.headline" :tip="tips" />. A format the field requires, or an instruction -- what to type in a box with nothing to go on yet, which addresses can be pasted whole, the row a new number is typed into -- stays under the field. Help, below, says which and why.

Choices

Every <select> is Basecoat's select: a button that shows the choice, and a list that opens from it in the top layer, over a dialog and outside any box that scrolls. The page writes an ordinary <select>, which is the whole control with scripts off and stays the form's control with them on; app.js builds the button beside it, as wide as its longest option, and the list each time it opens. An option can hold what a native one cannot: a flag beside every language and country, and an icon beside every kind -- a telephone number's, an address's, an identifier's, a link's service, an application's status on the board. The icons are generic, never a brand's mark. Time zones come in groups by continent, and a list of sixteen choices or more has a box at its head that narrows it to every choice holding what was typed, whatever the case or the accents.

It works as the ARIA combobox does: the arrows, Home, End and a letter open it and move in it, Enter and Space choose, Escape closes it having chosen nothing, and Tab chooses the option somebody moved to and moves on. Nothing is chosen by passing over it, and nothing by typing alone: what is typed in a long list's box puts the keys on the option it most likely means — the first that starts with it, then the first with a word that starts with it (a dialling code in front is not a word), then the first that holds it — and Enter chooses that, but Tab leaves the choice as it was until an arrow, Home, End, the page keys or the pointer has moved to one. In the box, Home and End move the caret. A press or a tap outside an open list closes it and does nothing else; a second press does what the first would have. A required list with no answer that is the form's first wrong control keeps the focus and says why under it. A control narrower than its choice shows one line of it and an ellipsis; the whole of it is in the list. Under a finger the options are 40 pixels tall. The open list is painted from the same tokens as the box, in both themes, for every select -- a column filter, a board card's status, the country in an input group -- not only those in a field; under forced colours it takes the system's pair. The gallery's Choices shows each kind. data-native keeps a select native, and tests/test_template_lint.py lists every one with its reason; the list is empty.

Cards and headings

Almost everything sits in a card. It is Basecoat's, with one of Basecoat's choices undone: a card here is a block and not a flex column, because it holds running content — a form, three paragraphs, a button after a sentence — and as flex items those would stretch to the card's width. What is taken from the structure is the <header>, with a place for an action beside the title, and the <footer> for what is done next.

The heading at the top of a section is <c-section-title>: a <header> holding the heading and, in its subtitle slot, the sentence under it. As a card's first child it is the card's header; anywhere else it is the same thing with the same paint. class is for the distance from what came before (class="mt-8"), and is the one thing a call site may add. Do not write the margin by hand: it used to stand in for whether a sentence followed, which is how one heading came to be written eleven ways.

help="…" on <c-section-title> puts the card's question mark at the inline end of the heading, its middle on the middle of the title's first line. <c-page-title help="…"> draws the same mark beside the H1 and no subtitle (Listings, Documents, Your details). A card that is a <fieldset> takes <c-help-mark> after its <legend>, and the mark then sits on the card's top border at the inline end, as the legend does at the start. See Help.

Width

A page takes the screen or keeps a measure, and what decides is where its measure lives (#188, #210, #320). A table, the board, the dashboard, a list, and a page framed by a sidebar empty main_width and take the width. A form's parts keep a measure of their own: max-w-2xl, 672 pixels, on the card or on a wrapper, at the start edge and never mx-auto, so a text box is 622 pixels on any monitor. Your details and every page of Settings do this beside their sidebar. The import's mapping is the exception, because it is two tables of a spreadsheet's own columns.

Under Server settings the frame keeps the measure for the page. server/base.html puts a measure block on the column beside the sidebar, and a page that is a form fills it in one line — {% block measure %}max-w-2xl{% endblock %} — so nothing in the page is wrapped or re-indented. Sign-in, Email, Capture, Defaults, Data protection, the record of processing and the three pages about one person (username, delete, recovery link) do. A page that leaves the block empty takes the width, and should be one that wants it: a table (People, Logs), a grid of cards (Overview, the gallery), or rows that set a description beside its controls (Plugins, and one person's plugins), where 672 pixels would leave the description 320. A form inside such a page keeps the measure on its own card, as Add a repository and the upload do on Plugins. tests/test_width.py lists every section under one heading or the other, so a new one has to say which it is.

A form whose parts are two different things sets them side by side from 2xl (1536 pixels): the company form's details and identifiers, a contact's details and its rows of numbers and links, and the capture form beside what it does. The row is 2xl:flex and each column max-w-2xl 2xl:flex-1, so both are 672 at any width from 1536. Source order is the reading order, and right to left mirrors it without code of its own. The breakpoint follows one rule: neither column may be narrower than the single column it replaces. A form whose column is wider than 672, such as the listing form at 768, would need a window of 1592, which no breakpoint names, so it stays one column.

Pills

A pill is a badge: a tag, a status, a plugin's kind, a count beside a sidebar entry. data-tone is its colour, from the palette. data-variant="chip" is the token in a field — a chosen label — a size up and outlined, with a .btn at icon-xs inside to take it off again, which is the 24 pixels a × in the corner of a pill needs; data-new marks one that is a name not yet saved.

tag and chip were two names for this one idea, and the idea was written out a third way nine times. Neither class exists any more.

A caution is amber: set in the environment, not loaded, a warning in the log. A state said beside a name is a word in a grey pill — Disabled — and a switch's own label names what it switches, Enabled, while the switch shows whether it is. A pill is never written out as utilities: rounded-full with px-2 py-0.5 on one element fails tests/test_template_lint.py.

Alerts

Something the page has to say is an alert with a data-variant of info, success, warning or error; none at all is info. The tone is never the only thing said: an alert is a sentence, and one that arrives after the page does carries role="alert" or role="status".

The region fixed to the foot of the window that reports a failed request is not one of these. It is page-alert, it is in the document from the first paint and empty until there is something to report, and it is wired to the script. Leave it alone.

Figures

A number and what it counts is <c-stat>: the figure in the slot, the label an attribute, card for a tile of its own and href for a tile that leads to the list it counts. tone is what the figure means, from the palette; a figure that is only a count has none.

Tables

A data table says <table class="table"> and nothing else: the header, the rows and the cells are bare, and the stylesheet draws them -- the header's small muted type over a rule, a lighter rule between rows and none after the last, the cell's padding. A cell that means something more says so on top (align-top, tabular-nums, text-end), and nothing repeats what the table already said. table-cards beside it is what a table does below md: a card per row, each cell introduced by its data-label.

A table that a person can sort, filter and reshape draws its header with <c-table.head>; a plain one writes its own <thead>. Both are the same table.

In that header a column's name sits at the start of the cell and its sort at the end, so a row of sorts lines up down the columns' end edges; a numeric column's name sits at the end beside its sort, over figures set against that end.

A table that narrows as you type has several controls that each ask the server for it: the page's filter form, every column's own filter, and the masthead's search box where it searches the table. They take turns on one element, the filter form (hx-sync="#…-filters:replace", and this:replace on the form itself), so a new request replaces the one in flight and the newest question is the one drawn and put in the address. Each sends the whole form and the box, so no request is half the question. The sort and the page links take no turn: their address was written with the last answer. The board's headings, which fold it, do take one: each sends the form and the box with its own status, so it is never an older question (see The board).

A page that answers an htmx request with its table alone, at the page's own address, takes PageOrFragmentMixin first among its bases: it chooses the template and says so in Vary, so that no cache hands the fragment to somebody asking for the page (#646).

A header holds the filters that belong to its column, and that can be more than one: the column's own first, then any question the table put there (Table.extra_filters), each drawn by <c-table.filter> from a Control. One that asks something other than the column's name has its word written above it. A list in a header is never narrower than its widest choice (min-w-max): it holds its column open and the table scrolls inside its own box, where a text box may be squeezed. The header cell says its own name (aria-label, Header.cell_label) — the column's, and that it is filtered while it is — so the controls in it keep their names and are not the column's. A filter whose column is not showing is under Narrow, which is then on the page at every width and comes back open while it narrows.

Narrow is a form of its own, drawn with the table: two copies of a filter never share a form, so a name is posted once (#622). It is not live; its button loads the page. What is entered in it and not yet applied outlives a swap: app.js puts back whether it was open, the fields that were changed and the focus, and leaves to the answer a filter the answer itself moved. Its word carries the funnel and a count while any of its fields is in force (Table.narrow_marks), counted at the width it is read at, and it stays folded for a filter a header holds. Below md the page's own filter form draws nothing (max-md:hidden), so a phone has one Apply, under the fields it sends. With scripts off there are two forms and two kinds of button; a <noscript> line under Narrow's says which applies what, and a header's panel ends in a button of its own wherever no form above the table is drawn.

A table with nothing matching still draws its header, over no rows: the header holds the sort and the filters, and is where a filter is loosened (#647). The header whose control asked for the table comes back open whatever it holds (#626).

The board

The board's columns fold, and the fold is the address: ?status= means what it means in the table. The server draws it — the column of each status it names open, every other a strip — so a plain link folds the board with scripts off, and Back, a reload and a bookmark agree with what is drawn (#315). Several statuses are one value, comma-separated (?status=applied,interviewing, #709), so that the hidden field, a heading's hx-vals and the table's one status list each carry the set; the table narrows to all of it, and its list offers the set, first, as a choice of its own while the address holds it.

  • A column's heading is the control, and says what pressing it draws (column.action): on the open board, only that column; on a strip of a folded board, that column too, beside the open ones (or alone where none is open, and the open board where it is the last); on one of several open columns, that one folded; on the one column open, all of them. The statuses a heading leaves open are written in the order of Status, so one set of columns is one address, and one Postulo does not have is left out. It carries aria-expanded for its column, aria-controls naming the column's list of cards, an aria-label that starts with the status and says what pressing it does (Applied: show only this column, show this column too, fold this column, show all columns), and aria-describedby naming the count beside it. Where htmx runs, app.js gives it role="button" and Space presses it, once however long it is held; without a script it is a link and is announced as one. A click with Ctrl, Meta or Shift is kept from htmx and left to the browser, which opens the link where it was asked to. The <h2> says its own name, the status, in an aria-label: left to its contents it would be called by its control.
  • Its focus ring is drawn inside it (-outline-offset-2), and it has the room for it. The board is a box that scrolls sideways, and such a box clips on every side: a ring drawn outside a control at its edge is cut.
  • A count is a figure and words. The figure is what is seen, aria-hidden; beside it, sr-only, is the page's own sentence for a count ("3 applications"), which is what the heading's control is described by.
  • A strip is the column's own <section>: the heading with the same control, the count, and the column's list of cards, empty and hidden. Its name is written down it ([writing-mode:vertical-rl] on a <span> in the link), so a strip is one line of text wide and as tall as its name is long, and nothing is cut in any language. The link is the strip's width and its ::after covers the strip, so the whole strip is the target. data-board-section is what a dragged card is dropped on, strip or not, and a strip under a held card is tinted itself, since its list is not on the screen.
  • All columns is an outline button above the board, before the columns in the source, drawn whenever a status is in the address and never otherwise. It names in data-focus-after the heading the focus goes to once it has gone. Where the fold is all the address asks and a default view is kept, it and the open column's heading lead to the plain board (saved=none), as Clear does: the bare address opens as the default view.
  • A fold takes its turn like a filter, not like a sort link. With a script the control asks for the page with the filter form and the masthead's box (hx-include), on the form's queue (hx-sync="#application-filters:replace"), with its own status in place of the form's (hx-vals) or with the status left out (hx-params="not status"). So it is the question as it stands, and the newest question is the one drawn.
  • The status in force is a hidden field of the filter form, drawn with the board (form="application-filters", inside #applications-table). The form is outside what a swap replaces; the field is inside it, so it is the status the board was last drawn with, and a filter chosen above a folded board keeps it folded. While a fold is on its way, app.js gives the field the status that fold asks for (made where the board had none, taken away by a fold that opens every column) as htmx sends it, so a filter chosen before the answer lands keeps the fold; a request for the board that fails puts the drawn status back.
  • Three counts, one rule each. An open column counts its cards. A strip's count is a COUNT of what the rest of the filters match in that status. The count under the page's title is of what the address asks for, the status included. A drop moves them by one at once, and moves them as the page that comes back will: under gone quiet the card leaves the board and its new column gains nothing, and a card that leaves what the address asks for — dropped on a strip, not on another open column — is one fewer under the title, in a sentence the server wrote for that (data-one-fewer), since app.js has no words of its own.
  • A card that is held is not swapped away. While a drag is in progress app.js refuses a request for the board (htmx:confirm) and an answer that lands (htmx:beforeSwap), and asks the filter form again when the card is let go without being moved. If it was moved, the address the card's form goes back to is written again from the filter form, so the refused filter is not lost.
  • The board scrolls in both directions inside the page (#652). .board-box bounds its height to the window under the masthead (max-block-size), so a column with hundreds of cards scrolls in the box and not the page. Each column's heading, and a strip's name, is position: sticky to the top of the box, so every name stays on screen. The box is a focusable role=region named Board, Tab leaves it, and the page scrolls on past it. The columns are a grid and not a flex row, because a flex row in a bounded box stretches its columns only to that bound and a short column's heading would have nowhere to stick below.
  • The board is centred. On the scroll box, a grid with one row (grid-flow-col auto-cols-max) and justify-center-safe centre the columns while they fit and start them at its edge when they do not, where centring would put the first column past an edge no scrolling reaches. After a board is drawn, app.js scrolls the box until the open columns are in its middle — or, where they do not fit, the column whose heading was pressed (a column wider than the box shows where it starts) — at once when the page arrives, and with the columns' motion after a swap (#709).
  • A fold moves. The board that is replaced is measured as the answer lands (each column's width, the gap, the box's height and scroll position) and copied; the new one is held at the old geometry until htmx's settle — which gives an element with an id its old classes for twenty milliseconds — and then each column that changes width is animated from the old width to the new (FOLD_TIME, 240 ms), with the gap, the box's height and its scroll position on the same beat, the scroll timed by the columns' own animation so it never stops short. A column's content is laid out at the width it arrives at through --board-arrives on the <section> (w-(--board-arrives) on its two children), never a style on the list of cards: htmx carries the style of an element with an id into the next board, which the CSP refuses. What the column held fades out over what it holds as a copy that is inert, aria-hidden, stripped of ids, names and board markers, and removed once it arrives. Under prefers-reduced-motion: reduce nothing animates: the board is drawn at its new widths and the box put where it goes.
  • A move leaves the board where it was. The card's form loads the page; how far the board was scrolled is kept for that one load in the tab's own storage, with the address the form goes back to, and put back on the page that address draws. A move made on the board says where the card went (Moved to Offer.); the application's own page and the dashboard keep Status updated.
  • The open column is brought into the box when the page arrives folded and after a swap that brought a board — not after a swap of anything else on the page, and not on the page a move loads.

A menu is <c-dropdown-menu>, and there is no other: the masthead's New, the account menu, More, a row's ⋮ on Server settings → People, a reminder's actions. The trigger is a <button popovertarget> and the panel a popover, so the browser opens and closes it with no script, closes it on Escape and on a click anywhere else, and hands focus back to the trigger. The panel is drawn in the top layer, where no box that scrolls can cut it off, and tied to its trigger with anchor positioning: below it, its inline end on the trigger's, turned upwards when there is no room below, and scrolling inside itself only when it fits on neither side. Where a browser has no anchor positioning, app.js places it by the same rules; with neither, it sits in the middle of the window, over the page and whole.

Two kinds. kind="menu", the default, is a menu of actions: role="menu", items that are role="menuitem", and the arrow keys. kind="navigation" is a disclosure of links: menu-item rows, no role="menu", and Tab. The disclosures that hold a form — the column chooser, the saved views, the language picker — are not menus, and stay <details>.

Dialogs

A question over the page, answered before anything is done, is <c-dialog>: Basecoat's alert dialog on the browser's own <dialog>. The heading goes in its title slot and the sentence in description, and they are its accessible name and description; both are slots, so a value somebody typed sits in a <bdi>. The buttons that answer go in footer. The component draws Cancel before them, so the answer that changes nothing is read first, reached first and holds the focus when the dialog opens. tone="plain" is a dialog that holds something to do rather than a question.

action makes the body a <form method="post"> to that address, with the token, whose buttons in footer submit it. A form cannot hold another, so a dialog with an action is drawn outside the page's form, and the button that opens it can sit inside one: it finds the dialog by id. live sends that form with htmx and leaves the answer to the page's script, and gives the dialog an alert of its own for a refusal or a failed request: class="alert" with data-variant="error" and role="alert", not drawn while it is empty. The page behind a modal is inert, so the alert at the foot of the window can be neither seen nor heard.

It opens without a script. The dialog is a popover and its trigger a <button popovertarget aria-haspopup="dialog">, the baseline the menus took: the browser opens it in the top layer and closes it on Escape, on Cancel and on a click outside. Opened that way it is not modal, and the page behind it is not dimmed, because it still works. Where a script runs, app.js opens the same dialog from the same button with showModal(): the page behind is inert and dimmed, focus stays inside, a click outside does nothing, and focus goes back to the button when it closes. So a confirmation has to be true both ways: its form posts and redirects with a message for the first, and says in a <noscript> what a reload costs.

A dialog that holds something to do has a page behind it. A confirmation is one button and survives as a popover; a form with a box to type in does not, and where the browser has no popover there is no way in at all. So such a dialog is opened by a link to a page that holds the same form: <a href="…" data-opens-dialog="id">. With no script the link is followed. With one, app.js opens the dialog the link names in its place — modal, focus given back to the link when it closes — and says so with aria-haspopup="dialog", which it adds itself, because without it the link is only a link. A click that asks for a new tab is left alone. The form is one component drawn in both places, and the dialog posts to the page's address. Server settings → Plugins does this for the identifier registry.

Three things go with it. The box asks for the focus (autofocus) and stands before Cancel, so opening the dialog lands in it. A refusal comes back into the open dialog: the form is live, the <c-dialog> carries hx-target naming the element that holds the fields, which the form inherits, and the view answers an htmx request with that element alone and HX-Reswap: outerHTML. Name the target in the markup and not with HX-Retarget: the element marked aria-busy while a request runs is the target as the request knew it, and one named only in the answer leaves the form dimmed and announced as loading for ever. And what was saved goes back to the page behind with HX-Redirect, where the message is.

data-size="wide" on a tone="plain" dialog gives it 48rem in place of 32rem, for a dialog that holds code; it is still the window less a gutter on a phone.

A box of code is not a box of prose. A textarea in the vocabulary grows with what is typed into it and never scrolls, which suits a note. data-code on a <textarea> gives it a height of its own — under half the window, 28rem at most — in one width of letter, with its lines unbroken, scrolling inside itself both ways; whoever wants it taller drags its corner. A hundred lines of JSON in a box that grew with them put what refused them two screens below the window.

What is wrong with it is said above it, and carries the line. The field's errors are drawn before the box, not after it, in the role="alert" element the box's aria-describedby names, so after a refusal they are the first thing read and the box they are about is the next. data-caret-line, and data-caret-column where it is known, on the box: app.js puts the caret there, scrolls the box — not the page — to that line, and then brings the sentences into view where the box asked for the focus, which is where something was refused. Once each time the box is drawn; a box inside a dialog that is shut has no size to scroll, and is placed when its dialog opens. A textarea has no line numbers, so the sentence names the line and the caret finds it. Without a script the box has the focus, the sentences are directly above it, and the box is short enough for both to be in the window.

A sentence that quotes what somebody typed isolates it. In the heading that is the <bdi>. A sentence that travels as plain text — into a live region, a message, an attribute — puts the value between U+2068 and U+2069 (removals.quoted()), or a number reads last group first in a right-to-left language.

The box is painted like a menu's panel, on the floating plane with a border: 32rem wide, or the window less a gutter, scrolling inside itself when a zoomed window is shorter than it. Its words break anywhere, because the heading may quote one word wider than a phone. Two of Basecoat's choices are undone: nothing fades or scales, and the footer is a row that wraps in the order written rather than a reversed stack.

Two controls that go with it on Your details. <c-remove-button :removal="…"> is the bin: an icon-only button in the destructive tone, 24 pixels, named with what it removes ("Remove +351 912 345 678"). <c-primary-choice> is a radio drawn as a star, filled when chosen and an outline otherwise, so the two states differ in shape as well as colour and still do under forced colours; the label is the 24-pixel target and the word Primary is there for a screen reader.

After the answer, focus moves and then the page speaks. Focus goes to its landing place first, and the polite live region is written once, a moment later, so a screen reader that drops its speech when focus moves does not cut the sentence short.

The gallery has one that asks about nothing. Delete account and the other confirmation pages are still pages.

Help

Help goes where the question is asked (#302), in one of three places. Your details uses all three, and the gallery shows each.

What a field is for is a tooltip. tip on <c-field> or <c-field-feedback> draws the field's help exactly as before — a paragraph under the control, carrying the id the control names in aria-describedby — and marks it data-tooltip. With scripts off that is where it stays. Where a script runs, app.js makes the paragraph a popover and shows it while the field is hovered or has the focus. It is the control's description either way, so a screen reader reads it with the field as it always did. It is not Basecoat's data-tooltip="…" attribute, which draws the text in a ::before: on hover only, hidden on focus, one line cut short, and not reliably read aloud. What is taken from Basecoat is the look: the page's ink as the ground and its ground as the ink, small type, a small rounding.

When it shows and when it goes (SC 1.4.13). At once when the focus enters the field; after 300 milliseconds when the pointer rests on it, so that crossing a form does not light every sentence on the way. It stays while the pointer is on the field, on the tooltip, or on the strip between them — a transparent part of the tooltip as wide as the two together — so there is no gap to cross at any speed, and for half a second after the pointer has left all three. It goes when the pointer and the focus have both left, or on Escape. It goes, too, the moment the field's own select list is opened, and none shows for that field while the list is open — the list is what is being read, and it opens where the tooltip would go; once the list has closed and the focus is back on its button, the field's tooltip shows again by the same rule. Escape puts the tooltip away and moves nothing; where it is the focused field's tooltip the key is its alone, so a dialog or a menu that is open stays open and the next Escape is theirs. A tooltip shown only for a resting pointer is put away by the same Escape, which then goes on to whatever has the focus. A touch does not hover; touching a control gives it the focus.

Where it is drawn: only where there is a clear place. A place is clear when the tooltip, put there and measured, is wholly inside the window that can be seen — under the floating masthead, above a phone's bar at the foot — and over no part of its own field, nor over a refusal or an alert, a mark or a note under a row (Not checked, Kept as it was, a place's note), a card's question mark, a select's list that is open, or the control that has the focus. The places are tried in order: above the field from its start edge, above from its end edge, above anywhere along the window, then the same three below; a question mark's sentence tries the end edge first. Where all that is in the way is a question mark or a side of the window, the tooltip stops short of it: it keeps its field's edge and narrows, or moves along the line, never to less than ten rem. Where no place is clear, the help is not a tooltip: it is the paragraph under its field, as with scripts off, until the window changes width. A window too short for a field and its tooltip — 400% zoom — gets the help under each field and no popover at all. A page that has been refused has no tooltips: the view says so for the whole page (refused, tips), and every card's sentence is under its title. The rule is app.js's, in one place, and it places every tooltip itself, in the page's co-ordinates, in every browser: not with CSS anchor positioning, which moves a box when the browser next draws rather than when it is measured. Twenty rem at most, or the window less a gutter; the words wrap and nothing has a height, so it grows with larger text and wider spacing.

What stays in sight. A format the field requires, or an instruction — the files an upload accepts, which addresses can be pasted whole, what to type into Location while there is no address to derive it from, the row a new telephone number is typed into — takes no tip: it has to be there before the mistake (SC 3.3.2). So does the sentence of a tick box, a radio or a switch; a tap does not give one the focus in every browser. A help text that says both is split: the format stays with the field and the rest goes to the card.

A card's help is behind a question mark. <c-section-title help="telephone-numbers" anchor="section-phones">, or <c-help-mark topic="…" anchor="…" /> after the <legend> of a card that is a <fieldset>. It draws circle-question-mark in the card's top corner at the inline end, 24 pixels square, named "Help: " and the card's name. Resting on it or focusing it shows the card's one sentence; pressing it, or Space, opens the card's help in a drawer. With scripts off the sentence is under the card's title and the question mark is a link that opens /help/<topic>/ in a new tab, and says so, so that nothing typed on the form is lost; the page holds the same help and leads back to the card. Where a script takes the link over it is a button, and the new tab and the words about it go. A card with nothing more to say than its one sentence gets no question mark; it keeps a subtitle. The mark is turned round where the page's language is written in the Arabic script, which writes a question mark the other way: the server says so from core/languages.py, and not in Hebrew.

The drawer is <c-drawer>: Basecoat's drawer on the browser's own <dialog>, at the inline end — the right in English, the left in Arabic — by a logical inset; inline-end is a side Basecoat does not have. Twenty-four rem wide, or the window less a gutter; the whole height of the window; its body scrolls inside it and takes the focus, so the keyboard can scroll it, and Close never shrinks below its 24 pixels. It is opened only by a link to the page that holds the same thing (data-opens-dialog), and only where a script runs, with showModal(): the page behind is inert and dimmed, the focus starts on Close and stays inside, and Escape, Close and a press outside the panel each close it and hand the focus back to the link. It is modal because a press outside has to close it, so it could not be read while typing in the page anyway, and a panel left open over the page would lie over the fields somebody had tabbed to. Nothing slides and nothing fades. Basecoat's drawer.js is not used.

The help itself is a template under templates/help/, listed in core/help.py and translated through the catalogues like every other string. Nothing is fetched from this wiki and nothing is bundled from it. The drawer and the page draw the same template, and what the code decides — what a picture may weigh, which registers' addresses can be pasted — is read from the code. CONTRIBUTING.md says how to add a topic.

The main navigation

One list, drawn once. core/navigation.py holds the items, each with its label, its address and a Lucide icon; the masthead draws them in the person's order into one <nav>, and the stylesheet decides its shape by width. From md up it is the row in the masthead, an icon beside each label. Below md the same element is a bar fixed to the foot of the window, an icon over each label. There is no second copy of the navigation for the phone — which is why it has one name and one place in the reading order everywhere.

The row never wraps. It holds the first items in the person's order and the rest go under More. How many it holds is decided by breakpoint, because without a script nothing can measure what fits:

Width In the line
below md (the bar) all of them — the bar scrolls sideways instead (#912)
md, 768 2 — the search is a link here, not a box; on Companies, Applications and Listings it is a button that opens the box in a bar under the masthead
lg, 1024 3
xl, 1280 5
2xl, 1536 7 — all of them today, and More goes

The numbers are what fits in Greek and German, the widest of the languages the reflow walk is taken in, in DejaVu Sans, the widest of the fonts the suite draws in, with the search box open — and, in English at 1280, under the text-spacing override. Where the box searches the page's table it has two buttons beside it, and the three take 186 pixels at rest, inside the 192 the open box takes; they carry no data-align, whose margin was eight pixels the count did not have. That box grows on focus only where a script has fitted the row. Where a script runs it measures instead and puts in the line as many as actually fit; it refines the count and never replaces it, so a script that fails leaves the count as the whole answer. Adding an item means checking the table still holds, in the browser, in those two languages.

The bar scrolls (#912). Below md there is no More: every item is in the bar, in the person's order, on one row. The slots share the width equally where they fit, each held at its longest word (and never under 44 pixels); where they do not, the bar scrolls sideways (overflow-x: auto, no flex-wrap). This reverses #299, under which the bar grew a second row rather than scroll — a rule for five slots that would have made a bar of three rows for seven. Reflow is unaffected: the bar scrolls in its own box, as a table does, and the page never does. An end with more past it is faded by a sticky pseudo-element over the items, so the last one in view is cut part way through; app.js marks those ends as data-overflow-start / data-overflow-end, scrolls the current item into view as the page opens, and scrolls a focused link clear of the fades (scroll-padding-inline, SC 2.4.11). --bottom-bar-height is still measured; with one row it changes only with the longest label's lines.

More is a disclosure that is navigation: <c-dropdown-menu kind="navigation">, whose rows are menu-item, never role="menu". It holds a copy of every item and shows the ones the line does not, so exactly one copy of each is ever drawn. It is the row's alone: below md it is not drawn. When the page you are on is under More, More is set in the weight the current link has. The script that fits the row measures More in both weights, because the bolder one is wider, and counts the one it will be drawn in.

The search bar below lg is the masthead's own form drawn as a popover: a bar the width of the window, fixed under the masthead, in the top layer. It must never lie over the element that has focus. Where scripts run it closes as focus goes anywhere but into it or onto its button. Where they do not, the stylesheet makes room while it is open: the root's scroll-padding-top grows by the bar's height (4.5rem), the skip link moves below it, and under scripting: none the page moves down by the same amount.

The bar has three neighbours that must know its height: the page's bottom padding, scroll-padding-bottom, and the failed-request alert, which sits above it. app.js measures it into --bottom-bar-height the way it measures the header into --header-height, and 4.5rem is the stylesheet's answer where no script runs. The bar is fixed to the window from inside the sticky header, so nothing on the header may start a containing block — no transform, filter, backdrop-filter, contain or will-change on it — or the bar would ride along at the top of the page.

Its labels wrap between words and never inside one. Five equal slots, none narrower than its longest word or than 44 pixels; where even the longest words cannot sit side by side, More goes to a second row rather than off the edge.

The order is the person's (Settings → Appearance): up and down buttons that post the whole form, like the column chooser's, so they need no script. What is stored is what was placed: an item in neither the order nor the hidden list — one a later release added — is drawn after the placed ones, and the default order is stored as nothing at all.

Nothing there

A list with nothing in it uses <c-empty>: an icon, a title, a sentence, and the thing to do next. It has three shapes and every "there is nothing here" in Postulo is one of them: the page's own, centred with room around it; variant="quiet", the note a widget or an aside leaves where its list would be -- a card with one muted sentence, at the start edge, because a dashboard tile is not a page; and variant="row", the same sentence inside a box that already is a card, a list with no rows. A sentence that changes with a condition goes in the slot, a second button in the footer slot. Nothing writes its own.

The distinction worth keeping is the button's variant. A page with nothing on it yet offers the thing to do next as a primary button. A filter that matched nothing offers Clear as an outline, because clearing is a way back rather than a way on.

There is no skeleton, deliberately. Postulo renders on the server, so there is no moment with nothing in it to fill; and while htmx replaces part of a page, what is on screen is the previous answer and still true. That state is opacity and a cursor, with no movement at all — something moving on every keystroke of a filter is worse than nothing for anybody who asked their system for less of it.

The tile

A person, a company and a plugin are the same tile: a box that holds a picture, or two letters on a colour the name chose, until a picture exists. Basecoat's avatar draws it, and the three template tags that used to write the box out by hand draw through one helper. The size classes sit on the box and the text size on the letters inside it, because the component sizes the letters itself and a utility on that span is what overrides it. A person is the tile cut all the way round; a company and a plugin cut it less. The one thing the stylesheet adds is that a picture fits rather than fills the box: a portrait cut to a square wants the same as a logo wider than it is tall.

Motion

Almost none, and always opted into:

@media (prefers-reduced-motion: no-preference) { … }

Nothing may move unless somebody's system has said it may.

One thing is moved by a script rather than by the stylesheet: the board scrolls sideways while a card is dragged near its edge. It asks the same question the other way round — matchMedia("(prefers-reduced-motion: reduce)") — and does nothing when the answer is yes. A browser's own scrolling under a drag, in the last few pixels of a box's edge, is the browser's and is left alone, and the script does not add to it: its own speed is at its fastest where the browser's band begins and falls to nothing half way into it.

Things you cannot do

These are enforced, and will fail the build rather than a review:

  • No style attribute and no <style> element. The policy is style-src 'self' with no unsafe-inline, enforced in the browser suite. A value that has to reach an element goes through a class or a data attribute — which is how stored column widths and the masthead's scrolled state work.
  • No physical sides. No ml-, pl-, text-left, left-*, border-l, rounded-l, in templates or stylesheets. Use the logical ones — ms-, ps-, text-start, start-*, border-s, rounded-s — so right-to-left keeps working.
  • Every control works with scripts off. Scripts add convenience, never capability. An inert control is worse than a missing one: if something cannot work without a script, the script adds it rather than animating a dead copy of it.
  • Rebuild the stylesheet and commit it. npm run build:css, every time; a test compares the committed file with a fresh build byte for byte.
  • New strings get an empty slot, and nothing more. scripts/messages.py extract writes it; the translation is made in Weblate, which drafts it by machine and sends it back as a pull request. A commit never writes one (scripts/messages.py guard).