The interface is grey, flat and has no system: give it tokens, colour, a second plane, and density as a preference #292

Open
opened 2026-09-19 17:40:45 +00:00 by tiagoagueda · 1 comment
Owner

Postulo's interface is correct and it is plain. Every page passes axe in both themes, reflows at 320 in three languages, survives the text-spacing override, keeps its focus rings under Windows High Contrast and prints as ink on white — #273 to #278 saw to all of it. None of that made it good to look at, and none of it was ever supposed to: conformance is a floor, not a design.

This issue is the part that is not framework adoption. #290 and #291 bring Basecoat's components in; this is about what they are painted with, and it is where the application would actually start to look like something somebody chose.

What the interface is, measured

It is grey. Counting every colour utility written in the 184 templates:

Family Uses
ink (the grey scale) 1,359
brand (indigo) 126
amber 66
red 37
emerald 11

Nine in ten colour decisions are a shade of grey, and every colour that is not the brand appears only to raise an alarm — amber for a warning, red for an error, emerald for a success. Colour is never used for structure, for grouping, for telling one kind of thing from another, or for pleasure. That is not an accessibility requirement: the criteria constrain which colours may carry meaning and at what contrast, never whether an interface may have any.

It is flat. Two shadow-lg in 184 templates, and .card carries shadow-sm (assets/css/app.css:456). No gradients anywhere. There is exactly one plane, so nothing is above anything else — a popover, a card and the page are all the same distance from the reader.

It has no system to be consistent with. assets/css/app.css defines one @theme block (:69-129) holding a font stack, an 11-step ink scale, a 10-step brand scale, and Basecoat's semantic aliases. There is no radius token, no shadow token, no spacing token and no type-scale token — none, anywhere. Basecoat's base.css would normally supply them and #262 deliberately did not import it, for reasons that were right about the dark variant and the fonts and said nothing about radii.

So every template decides for itself, and they disagree — measurably, at every level:

  • The page title is written six ways. mb-1 text-2xl font-semibold tracking-tight (43), the same without the margin (24), text-2xl font-semibold tabular-nums (12), mb-6 … (9), mb-4 … (1), mb-2 … (1).
  • A section heading is written eleven ways. <h2> carries mb-3 font-medium 40 times, mb-4 font-medium 19, bare font-medium 16, mb-1 font-medium 13, mb-2 font-medium 11, and six more. api/token_list.html uses mb-4 at one card and mb-3 at another. An <h3> is sometimes smaller than its <h2> and sometimes the same size.
  • Two adjacent buttons are spaced five ways: gap-3 (30), gap-2 (7), flex-wrap gap-2 (6), gap-1 (5), flex-wrap gap-1 (5), with no rule tying the gap to anything.
  • A page header is arranged three ways — items-center gap-4 (16), items-start gap-4 (8), items-start justify-between gap-4 (2) — and inside it the title column is grow basis-48 nine times and a bare <div> otherwise, while the action is a wrapper ms-auto fifteen times and class="btn ms-auto" on the button itself nine times. Four list pages, four structures, one intended result.
  • A key-and-value list is laid out ten ways, and the <dt> beside a value is at three different sizes depending on which page you are on.
  • A statistic has no component at all: applications/report.html repeats the same <dt>/<dd class="text-2xl font-semibold tabular-nums"> tile nine times inline, and the dashboard widgets draw the same idea again separately.

Rounding is rounded-full (20), rounded-lg (11), rounded-xl (2), rounded-md (1) in templates, plus whatever each class in app.css chose — .card is rounded-xl, .btn is rounded-lg, .field-input is its own. There is no rule, because there is no token to be a rule.

Colour it already has and does not use. app.css:384-410 defines seven tag colours — grey, blue, amber, violet, teal, green, rose, each tuned to 3:1 against the card in both themes. One of them is used anywhere (tag-grey, once). The other six are dead code waiting for #285, which asks for exactly this. The palette exists; nothing spends it.

There is no motion. One transition in the whole stylesheet, on the funnel bar, and it is opted into behind prefers-reduced-motion: no-preference (app.css:773-777) — which is the right shape and the only instance of it.

And nothing is written down. There is no design page in the wiki's 23, and CONTRIBUTING.md describes the mechanics — components live in templates/cotton/, Basecoat draws them, icons come from Lucide — without a word about what the interface should look like. There is no gallery page where a change could be seen.

What to do

1. Give the system its missing tokens. A --radius and a rounding scale derived from it; two or three elevation tokens rather than one shadow-sm and two strays; a type scale and a spacing rhythm named once. These belong in the @theme block beside the colours, and once they exist the six ways of writing a page title become one component.

2. Spend the colour that is already there. The brand scale has ten steps and the interface uses a handful. Section accents, the current state of things, the funnel and the report's chart, the tag palette that #285 will light up, the kind-chips on the plugin pages: all of these can carry colour that means something, at the contrast the tests already enforce.

3. Give the interface a second plane. Cards raised off the page, popovers and menus above cards, the sticky header with a real edge when the page is scrolled under it. Three levels is enough and one is not.

4. Fill the states that are missing. Basecoat's empty and skeleton need no script and Postulo has neither: a list with nothing in it is a bordered box with a sentence, and a slow page is a spinner. Those two are the largest "unfinished" impressions in the application.

5. Make density a preference, not a compromise. This is the part the per-user machinery now makes possible. A comfortable default and a compact option, chosen per account — the profile already carries theme, hidden_nav_items, show_career_order and keyboard_shortcuts, and #281 has just given the preferences a section of their own. A person who wants more rows on a screen asks for them; the default stays generous. The same mechanism answers #289's underline.

6. Write it down, and make it visible. A short design page in the wiki saying what the tokens are and when to use each, and a gallery page in the application showing every component in both themes — which is also the page a browser test should walk, because a component that is only ever seen inside a feature is a component nobody reviews.

What may not be traded for it

The floor is tested, in detail, and the redesign has to land on top of all of it:

  • axe over every page in both themes, with IGNORED_RULES empty — not one rule is excused today and none should be.
  • Contrast is computed from the tokens themselves by tests/test_contrast.py, not from screenshots: the field boundary at 3:1 against both the field and the page, in both themes, and the focus ring opaque. Its parser reads the @theme block positionally and understands only literal oklch(L C H) values, so a token written any other way, or the dark block moved, makes it silently stop checking. If the tokens are restructured, that parser must be fixed in the same commit, not afterwards.
  • Target size 24×24, with the exemption dictionary empty. Density is exactly what this catches: a compact option must still pass, or it must be the spacing exception and proved so.
  • Reflow at 320 in English, Greek and German, the text-spacing override, and 200% zoom at 640.
  • Forced colours: two deliberately unlayered blocks put back the borders and focus outlines a high-contrast theme discards. Anything new that carries meaning in a background or a shadow needs its own answer there, because both are thrown away.
  • The content security policy: style-src 'self' with no unsafe-inline, enforced in the browser suite. No style attribute and no <style> element — so nothing in this may be implemented by writing a value into markup. The stored column widths already had to be applied through the CSSOM for this reason.
  • Print: a dark profile prints as ink on white, which the ink scale inverts for. New tokens need the same treatment.
  • Every control still works with scripts off.

Points to settle

  • How much of this is one change and how much is many. The tokens are one commit and everything after them is per-surface. Doing it page by page leaves the application half-redesigned for a while, which is worse-looking than either end; doing it at once is a diff nobody can review. A gallery page first, then the tokens, then a surface at a time, is the order that keeps both reviewable.
  • Whether the brand indigo is the right brand. It is described as "sober enough for something you use while job hunting", which is a real argument and worth re-examining deliberately rather than by accident.
  • Whether density belongs under Appearance or Accessibility. It is both; #281 put the two accessibility toggles in their own section and left Gone quiet under Appearance, so there is a precedent for deciding by what the person is looking for rather than by what motivated it.

Notes for whoever takes it

  • npm run build:css and the compiled stylesheet committed, every time; tests/test_stylesheet.py compares them byte for byte.
  • tests/test_template_lint.py forbids physical sides (ml-, pl-, text-left, left-*, border-l, rounded-l) in templates and in both stylesheets. A new rounding or spacing scale must be logical throughout.
  • New strings into fr-fr, pt-pt, pt-br as draft.
  • Related: #285 (tag colour and icon) is the first real spend of the palette; #289 (the navigation underline as a preference) is the first visual preference; #282 (rebuild the masthead on the component layer) is the first surface that would want all of this.
Postulo's interface is correct and it is plain. Every page passes axe in both themes, reflows at 320 in three languages, survives the text-spacing override, keeps its focus rings under Windows High Contrast and prints as ink on white — #273 to #278 saw to all of it. None of that made it good to look at, and none of it was ever supposed to: conformance is a floor, not a design. This issue is the part that is not framework adoption. #290 and #291 bring Basecoat's components in; this is about what they are painted with, and it is where the application would actually start to look like something somebody chose. ## What the interface is, measured **It is grey.** Counting every colour utility written in the 184 templates: | Family | Uses | | --- | --- | | `ink` (the grey scale) | 1,359 | | `brand` (indigo) | 126 | | `amber` | 66 | | `red` | 37 | | `emerald` | 11 | Nine in ten colour decisions are a shade of grey, and **every colour that is not the brand appears only to raise an alarm** — amber for a warning, red for an error, emerald for a success. Colour is never used for structure, for grouping, for telling one kind of thing from another, or for pleasure. That is not an accessibility requirement: the criteria constrain *which* colours may carry meaning and at what contrast, never *whether* an interface may have any. **It is flat.** Two `shadow-lg` in 184 templates, and `.card` carries `shadow-sm` (`assets/css/app.css:456`). No gradients anywhere. There is exactly one plane, so nothing is above anything else — a popover, a card and the page are all the same distance from the reader. **It has no system to be consistent with.** `assets/css/app.css` defines **one `@theme` block** (`:69-129`) holding a font stack, an 11-step ink scale, a 10-step brand scale, and Basecoat's semantic aliases. There is **no radius token, no shadow token, no spacing token and no type-scale token** — none, anywhere. Basecoat's `base.css` would normally supply them and #262 deliberately did not import it, for reasons that were right about the `dark` variant and the fonts and said nothing about radii. So every template decides for itself, and they disagree — measurably, at every level: - **The page title is written six ways.** `mb-1 text-2xl font-semibold tracking-tight` (43), the same without the margin (24), `text-2xl font-semibold tabular-nums` (12), `mb-6 …` (9), `mb-4 …` (1), `mb-2 …` (1). - **A section heading is written eleven ways.** `<h2>` carries `mb-3 font-medium` 40 times, `mb-4 font-medium` 19, bare `font-medium` 16, `mb-1 font-medium` 13, `mb-2 font-medium` 11, and six more. `api/token_list.html` uses `mb-4` at one card and `mb-3` at another. An `<h3>` is sometimes smaller than its `<h2>` and sometimes the same size. - **Two adjacent buttons are spaced five ways**: `gap-3` (30), `gap-2` (7), `flex-wrap gap-2` (6), `gap-1` (5), `flex-wrap gap-1` (5), with no rule tying the gap to anything. - **A page header is arranged three ways** — `items-center gap-4` (16), `items-start gap-4` (8), `items-start justify-between gap-4` (2) — and inside it the title column is `grow basis-48` nine times and a bare `<div>` otherwise, while the action is a wrapper `ms-auto` fifteen times and `class="btn ms-auto"` on the button itself nine times. Four list pages, four structures, one intended result. - **A key-and-value list is laid out ten ways**, and the `<dt>` beside a value is at three different sizes depending on which page you are on. - **A statistic has no component at all**: `applications/report.html` repeats the same `<dt>`/`<dd class="text-2xl font-semibold tabular-nums">` tile nine times inline, and the dashboard widgets draw the same idea again separately. Rounding is `rounded-full` (20), `rounded-lg` (11), `rounded-xl` (2), `rounded-md` (1) in templates, plus whatever each class in `app.css` chose — `.card` is `rounded-xl`, `.btn` is `rounded-lg`, `.field-input` is its own. There is no rule, because there is no token to be a rule. **Colour it already has and does not use.** `app.css:384-410` defines seven tag colours — grey, blue, amber, violet, teal, green, rose, each tuned to 3:1 against the card in both themes. **One of them is used anywhere** (`tag-grey`, once). The other six are dead code waiting for #285, which asks for exactly this. The palette exists; nothing spends it. **There is no motion.** One transition in the whole stylesheet, on the funnel bar, and it is opted into behind `prefers-reduced-motion: no-preference` (`app.css:773-777`) — which is the right shape and the only instance of it. **And nothing is written down.** There is no design page in the wiki's 23, and `CONTRIBUTING.md` describes the mechanics — components live in `templates/cotton/`, Basecoat draws them, icons come from Lucide — without a word about what the interface should look like. There is no gallery page where a change could be seen. ## What to do **1. Give the system its missing tokens.** A `--radius` and a rounding scale derived from it; two or three elevation tokens rather than one `shadow-sm` and two strays; a type scale and a spacing rhythm named once. These belong in the `@theme` block beside the colours, and once they exist the six ways of writing a page title become one component. **2. Spend the colour that is already there.** The brand scale has ten steps and the interface uses a handful. Section accents, the current state of things, the funnel and the report's chart, the tag palette that #285 will light up, the kind-chips on the plugin pages: all of these can carry colour that means something, at the contrast the tests already enforce. **3. Give the interface a second plane.** Cards raised off the page, popovers and menus above cards, the sticky header with a real edge when the page is scrolled under it. Three levels is enough and one is not. **4. Fill the states that are missing.** Basecoat's `empty` and `skeleton` need no script and Postulo has neither: a list with nothing in it is a bordered box with a sentence, and a slow page is a spinner. Those two are the largest "unfinished" impressions in the application. **5. Make density a preference, not a compromise.** This is the part the per-user machinery now makes possible. A comfortable default and a compact option, chosen per account — the profile already carries `theme`, `hidden_nav_items`, `show_career_order` and `keyboard_shortcuts`, and #281 has just given the preferences a section of their own. A person who wants more rows on a screen asks for them; the default stays generous. The same mechanism answers #289's underline. **6. Write it down, and make it visible.** A short design page in the wiki saying what the tokens are and when to use each, and a gallery page in the application showing every component in both themes — which is also the page a browser test should walk, because a component that is only ever seen inside a feature is a component nobody reviews. ## What may not be traded for it The floor is tested, in detail, and the redesign has to land on top of all of it: - **axe over every page in both themes**, with `IGNORED_RULES` empty — not one rule is excused today and none should be. - **Contrast is computed from the tokens themselves** by `tests/test_contrast.py`, not from screenshots: the field boundary at 3:1 against both the field and the page, in both themes, and the focus ring opaque. Its parser reads the `@theme` block *positionally* and understands only literal `oklch(L C H)` values, so a token written any other way, or the dark block moved, makes it silently stop checking. **If the tokens are restructured, that parser must be fixed in the same commit**, not afterwards. - **Target size 24×24**, with the exemption dictionary empty. Density is exactly what this catches: a compact option must still pass, or it must be the spacing exception and proved so. - **Reflow at 320 in English, Greek and German**, the text-spacing override, and 200% zoom at 640. - **Forced colours**: two deliberately unlayered blocks put back the borders and focus outlines a high-contrast theme discards. Anything new that carries meaning in a background or a shadow needs its own answer there, because both are thrown away. - **The content security policy**: `style-src 'self'` with no `unsafe-inline`, enforced in the browser suite. **No `style` attribute and no `<style>` element** — so nothing in this may be implemented by writing a value into markup. The stored column widths already had to be applied through the CSSOM for this reason. - **Print**: a dark profile prints as ink on white, which the ink scale inverts for. New tokens need the same treatment. - Every control still works with scripts off. ## Points to settle - **How much of this is one change and how much is many.** The tokens are one commit and everything after them is per-surface. Doing it page by page leaves the application half-redesigned for a while, which is worse-looking than either end; doing it at once is a diff nobody can review. A gallery page first, then the tokens, then a surface at a time, is the order that keeps both reviewable. - **Whether the brand indigo is the right brand.** It is described as *"sober enough for something you use while job hunting"*, which is a real argument and worth re-examining deliberately rather than by accident. - **Whether density belongs under Appearance or Accessibility.** It is both; #281 put the two accessibility toggles in their own section and left *Gone quiet* under Appearance, so there is a precedent for deciding by what the person is looking for rather than by what motivated it. ## Notes for whoever takes it - `npm run build:css` and the compiled stylesheet committed, every time; `tests/test_stylesheet.py` compares them byte for byte. - `tests/test_template_lint.py` forbids physical sides (`ml-`, `pl-`, `text-left`, `left-*`, `border-l`, `rounded-l`) in templates **and in both stylesheets**. A new rounding or spacing scale must be logical throughout. - New strings into `fr-fr`, `pt-pt`, `pt-br` as `draft`. - Related: **#285** (tag colour and icon) is the first real spend of the palette; **#289** (the navigation underline as a preference) is the first visual preference; **#282** (rebuild the masthead on the component layer) is the first surface that would want all of this.
tiagoagueda added this to the 0.5.0 milestone 2026-09-19 17:40:45 +00:00
Author
Owner

Five of the six parts are on main; two pieces are left and both are per-surface work rather than one pass.

Landed

Part Commit
The gallery: every component on one page, walked by the browser suite 44c6e3a77
Catalogue references, and the test that catches them abbbfed04
One --radius with the scale derived from it; three planes; the masthead's edge 69c01b9c7
One empty state replacing eight of sixteen hand-written ones a013e5bb8
Density as a per-account preference, walked compact for 24x24 3ae59b2d5
The wiki's Design page, and CONTRIBUTING pointing at it c2ad54b60 + wiki 28c72a1

Two decisions taken along the way

No skeleton, deliberately. This issue asks for one on the grounds that a slow page is a spinner. It is not: #226 already settled what a page does while htmx replaces part of it -- opacity, a cursor, no movement, because what is under it is the previous answer and still true. A skeleton belongs where there is nothing yet, and Postulo renders on the server, so there is no such moment. Importing it would be dead code or a contradiction.

The rounding values did not change. The scale is derived from one token and computes to Tailwind's own values to the pixel, so no corner in the application moved. The commit is the rule, not a restyle; the gallery is there for whoever changes the base.

What is left

  1. Spend the colour. Untouched. The tag palette is now visible in the gallery but nothing spends it -- that is #285's job and is better done there. The report's chart, the funnel and section accents are still grey and are the honest remainder of this item.

  2. The heading components. A page title is still written six ways and a section heading eleven. The component looked like a mechanical pass and is not: seventeen pages put the title inside a flex header with the actions beside it, where a wrapper with its own margin breaks the layout, and the rest are standalone. A component that fits both shapes needs per-page judgement, which is what this issue means by "a surface at a time". A half-written one was started and removed rather than left as a component that does not fit its call sites.

Both are separable and neither blocks the others. Worth deciding whether they stay here or become issues of their own.

Five of the six parts are on `main`; two pieces are left and both are per-surface work rather than one pass. **Landed** | Part | Commit | | --- | --- | | The gallery: every component on one page, walked by the browser suite | `44c6e3a77` | | Catalogue references, and the test that catches them | `abbbfed04` | | One `--radius` with the scale derived from it; three planes; the masthead's edge | `69c01b9c7` | | One empty state replacing eight of sixteen hand-written ones | `a013e5bb8` | | Density as a per-account preference, walked compact for 24x24 | `3ae59b2d5` | | The wiki's Design page, and CONTRIBUTING pointing at it | `c2ad54b60` + wiki `28c72a1` | **Two decisions taken along the way** *No skeleton, deliberately.* This issue asks for one on the grounds that a slow page is a spinner. It is not: #226 already settled what a page does while htmx replaces part of it -- opacity, a cursor, no movement, because what is under it is the previous answer and still true. A skeleton belongs where there is nothing yet, and Postulo renders on the server, so there is no such moment. Importing it would be dead code or a contradiction. *The rounding values did not change.* The scale is derived from one token and computes to Tailwind's own values to the pixel, so no corner in the application moved. The commit is the rule, not a restyle; the gallery is there for whoever changes the base. **What is left** 1. **Spend the colour.** Untouched. The tag palette is now visible in the gallery but nothing spends it -- that is #285's job and is better done there. The report's chart, the funnel and section accents are still grey and are the honest remainder of this item. 2. **The heading components.** A page title is still written six ways and a section heading eleven. The component looked like a mechanical pass and is not: seventeen pages put the title *inside* a flex header with the actions beside it, where a wrapper with its own margin breaks the layout, and the rest are standalone. A component that fits both shapes needs per-page judgement, which is what this issue means by "a surface at a time". A half-written one was started and removed rather than left as a component that does not fit its call sites. Both are separable and neither blocks the others. Worth deciding whether they stay here or become issues of their own.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#292
No description provided.