The UI grew one panel at a time, so this file is the constitution that keeps new
panels consistent. CDN Tailwind is used (no build step), so shared component
styles live as raw CSS in base.html's <style> — @apply is not available.
Templates do not name palette colours. There is no text-zinc-400, no
bg-emerald-500. A template names what the colour is for — text-muted,
bg-accent — and the active theme decides the shade.
This is not a preference. A named shade is the same shade in every theme, so it
survives into the theme where it can't be read. Going dark-only → light had to
touch 1,459 of them across 989 lines for exactly that reason (the grep below,
run against db8dda0); the roles exist so it never has to happen again.
Defined as --c-* triplets in templates/base.html and exposed to Tailwind
under the same names, so bg-panel, text-body, border-line, and the opacity
forms (bg-accent/10, text-danger/90) all work.
| Role | What it is | Where it goes |
|---|---|---|
canvas |
the page ground | <body>; also the text on a warn/danger fill |
panel |
a panel's surface | cards, the drawer |
raised |
one step above panel |
hovered rows, chips |
input |
an input's surface | .field (sinks on dark, stays white on light) |
line |
hairline | card edges, dividers, tab strips |
line-strong |
the stronger line | control and input borders |
heading |
the highest-contrast text | headings, an active tab's label |
strong |
strong body text | table cells, labels that carry a value |
body |
ordinary text — the most common one | paragraphs, help text |
muted |
secondary text | column labels, counts, captions |
faint |
the faintest readable text | placeholders, hints |
accent |
primary action / success | Create, Save, Add, Rename confirm |
on-accent |
the text on an accent fill |
see below — it is not interchangeable |
danger |
destructive | Drop, Delete, Truncate |
warn |
proceed with care | restore-into-existing, oversized, write mode |
info |
information / neutral link | "open in SQL", downloads, read links |
catalog |
wayfinding for Catalog & data | the bento group only |
Two more --c-* values are opacities, not colours: --c-scrim (the drawer's
backdrop) and --c-shadow (its shadow). Both follow the ground's lightness.
If you want a colour that isn't in the table, you don't need a new colour — work out which of these roles it already is. A thing that isn't an action, a warning, a link, or wayfinding is text or structure, and those are all in the table.
bg-accent→text-on-accent. The accent is user-chosen in a custom theme, so the readable text on it is derived from the accent's lightness.bg-warn,bg-danger→text-canvas. The intent colours are fixed pairs (light shades on a dark ground, dark shades on a light one), so the ground itself is always the readable side.
Using text-on-accent on a warn fill looks right in both presets and breaks in
a custom theme: seed a dark ground with a dark accent and on-accent becomes
white, which lands white text on amber at 1.9 : 1. With text-canvas the
same theme measures 11.1 : 1.
grep -rEn '(text|bg|border|ring|fill|stroke|divide|accent|placeholder|from|via|to)-[a-z]+-[0-9]{2,3}' \
--include='*.html' templates core planner_labMust print nothing. It currently does, across all 38 templates. (docs/ is the
marketing site — standalone pages with their own palette, not part of the app.)
The workspace home (partials/_bento.html) is a launcher, so its groups carry a
colour purely for wayfinding: Query & planner = accent, Live ops = info,
Catalog & data = catalog, applied to the icon and a faint tint/border. This is
the one place colour means "which group" rather than "what happens". Every real
panel follows the intent rules above.
A role has to be declared in five places in base.html, or it will be
undefined in one theme and inherit whatever came before:
- the
colorsmap in the inlinetailwind.config(so Tailwind emits the class) :root— the dark defaults[data-theme="light"]and theprefers-color-scheme: darkblock that backs 自動derive()— how a custom theme computes it from its three seedsclearInline()— the list of properties stripped when leaving custom
The header offers 自動 / 明 / 暗 / 自作. The choice lives in localStorage
(cli2ui-theme, plus cli2ui-custom for the three seed colours) and is applied
in <head> before first paint, so there is no flash of the wrong theme. It is
never sent to the server — no cookie, no request. cli2ui runs locally and the
theme is a property of the browser looking at it.
A custom theme takes three colours — ground, text, accent — and derives the rest. Two things it deliberately does:
- Faint text is clamped.
mutedandfaintare made by pulling the text colour toward the ground, but a theme whose ground and text are already close (Solarized) sank to 2.0 : 1.fade()binary-searches back to the last point that still holds 3.0 : 1. - It says when the seeds are the problem. If ground and text are closer than 3.0 : 1, no derivation saves the screen, so the panel says so instead. The threshold is 3.0 on purpose: 4.5 fired on Solarized Light (4.1 : 1), a theme people really use, and a warning that cries wolf stops being read.
Defined in base.html. Compose .btn with one intent modifier:
.btn .btn-primary—accentfill,on-accenttext (the main action).btn .btn-neutral—line-strongoutline (secondary / toggle).btn .btn-danger—dangeroutline (destructive).btn .btn-warn—warnoutline (proceed-with-care).btn .btn-link— borderlessinfotext link ("open in SQL", downloads)- add
.btn-smfor the compact size used inside table rows
:disabled is handled by the base class (40% opacity, not-allowed).
.field— the standard input/select:inputsurface,line-strongborder,rounded-lg,accentfocus border,faintplaceholder. Add.field-smfor the in-row size.- Checkboxes:
class="accent-accent"— Tailwind'saccent-utility (the CSSaccent-colorproperty) set to theaccentrole. The doubled word is not a typo.
- Cards / panels:
rounded-xl. Controls (buttons, inputs):rounded-lg. Never barerounded(4px) orrounded-md— those read as a third radius. - Card header padding:
px-5 py-2.5. Control padding comes from.btn/.field.
Use {% include "partials/_banner.html" %} for error / notice messages instead
of hand-rolling the box. It picks the tone from error (danger) or notice
(warn if the text contains "⚠", else info).
The floor is 3.0 : 1 for any text against the surface behind it, in every theme — not just the two presets. Two things make this easy to get wrong:
- A role is only safe on the surfaces it was built for.
lineandline-strongare border colours; used as text they measure around 1.7 : 1 even on dark. Faint text isfaint, which is clamped for exactly this. - Alpine
:classbindings don't apply to injected HTML. When you check a panel, check the state htmx actually renders, not the one the binding would have produced.
Measure rather than judge by eye — read the computed colours and compute the
WCAG ratio, (L1 + .05) / (L2 + .05) over relative luminance.
The measured worst case, across both presets and two custom seeds, is the filled warn button on a light ground: 3.2 : 1 on the light preset, and 2.95 : 1 seeded with Solarized Light. That is the floor's weakest point — a fixed amber can only be so dark before it stops reading as amber. Anything new that lands under it is a bug, not a precedent.
Every panel is translatable. New templates need {% load i18n %} at the top,
then wrap user-visible text with {% trans "…" %} (or {% blocktrans %} for
text with {{ vars }} / plurals). Python user-facing messages (errors, notices)
use from django.utils.translation import gettext as _ and _("…").
Do not wrap: SQL, pg_* identifiers, config keys, code samples, or anything
inside mono/<code> — those stay verbatim in both languages. JA strings live in
locale/ja/LC_MESSAGES/django.po; after adding strings run
django-admin makemessages -l ja then compilemessages -l ja and commit both
.po and .mo. The header toggle (set_language, cookie-based) switches
language; with no cookie it falls back to the browser's Accept-Language.