Table of Contents
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-colorsblocks 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-sminink-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
styleattribute and no<style>element. The policy isstyle-src 'self'with nounsafe-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-ptandpt-br, flaggeddraft.
Postulo
Running it
- Installing Postulo
- Configuration
- Accounts and invitations
- Backups and your data
- Hardening
- Accessibility
- Health, metrics and logs
- Troubleshooting
Using it
- Getting started
- Listings
- Tracking applications
- Capturing postings
- Insights and the dashboard
- Reports
- Your career record
- CVs and portfolios
- Letters
- Files and what you sent
Building on it
Project