5 Design
Tiago Águeda edited this page 2026-09-25 14:34:45 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Design

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 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 tag 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.

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 — 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: text-2xl font-semibold tracking-tight
  • a section heading: 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

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.

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 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.

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.

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.

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.

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 into fr-fr, pt-pt and pt-br, flagged draft.