Files
verdande/web/src/app.css
T
Kristian WindandClaude Opus 5 550f420c59 feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has
defaulted to 'graphite' on every row ever written, and nothing has ever
read it. There was no palette, no name-to-colour mapping, and the dot in
the sidebar was hardcoded to a border grey. So this is not "add a picker";
it is implementing colour for the first time.

Names, not hex values. A hex in ten thousand rows is a decision nobody can
revisit; a name is resolved when the interface paints and can be retuned
for a new theme or a new display. It is also the difference between a
database that stores a fact and one that stores a rendering. The set is
closed and refused at the edge — an unknown name would be stored happily
and painted as the default, which looks like a colour that did not save.

One palette across all five themes, and this is the one place in app.css
that is not redefined per theme. Everything else there is text, a ground or
a border and has to be measured against what sits behind it. These are
small solid marks beside a label that already carries the meaning: what
they owe is to be told apart, and to be visible on both a near-black and a
white ground, which is why all ten sit in the mid-tone band. It also keeps
them out of the trap the themes are in — a colour added to four blocks and
forgotten in the fifth looks finished and falls back silently. There is no
fifth block to forget.

The list is written down three times: Go for validation, colors.js for the
picker, app.css for the tokens that paint. So a test reads the other two —
the same small-scanner approach openapi_test.go takes to the spec — and
fails if the server accepts a colour nothing can draw, or the picker offers
one the server refuses.

Groups get a colour of their own (migration 0006) and projects inside a
group are indented. The indent is to the group's *name*, not to its dot:
the eye follows the text, and lining the projects up under the dot leaves
them looking a step short of where the heading starts. The drop line is
indented with them, so a drop inside a group reads as landing in the group.

The group's dot is the control as well as the preview — you press the thing
you are about to change, as on the project page. That was not the first
design: a third text action, "Farve", sat beside Omdøb and Slet, and those
carry `opacity: 0` rather than `display: none`, so they take their width
whether or not you can see them. A group called "Arbejde" rendered as
"ARBEJD". Moving the control onto the dot gave the name its room back.

The smoke test measures the indent at the content edge rather than the
element's, because the indent is padding and the box does not move — which
is how the first version of that assertion passed 12 against 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:50:20 +02:00

421 lines
11 KiB
CSS

/*
* verdande — design tokens.
*
* Nordic minimalism: a near-neutral ground, generous space, and one accent that
* does all the signalling. The greys are not pure grey — they carry a trace of
* green, which is what stops a dark interface from reading as cold blue-black and
* is most of the difference between "calm" and "unfinished".
*
* Everything is a token. A component that reaches for a raw hex is a component
* that will be wrong in one of the two themes.
*/
:root {
/* Neutrals, dark theme. Named by role, not by lightness, so the light theme
below can invert the scale without every name becoming a lie. */
--ground: #111312;
--surface: #171a19;
--surface-raised: #1d211f;
--surface-sunken: #0d0f0e;
--line: #262b29;
--line-strong: #343a37;
--ink: #e8ebe9;
--ink-muted: #9aa39e;
/* The quiet tier, and the one that decides whether the interface is
readable: dates, counts and hints all live here. Lifted from #6b736f,
which sat at 3.8:1 against the ground — under AA, and it showed. */
--ink-faint: #828a84;
/* Nordlys-grøn. One accent, used for the checkmark, focus rings, and the
single primary action on a screen — and for nothing else, which is what
keeps it meaning "this one". */
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #1c3329;
--accent-ink: #0d1411;
/* Priorities. Red is reserved for P1, so it never appears decoratively — if
something is red on this screen, it is urgent. */
--p1: #cf6b6b;
--p2: #d69f5c;
--p3: #6b9ecf;
--p4: var(--ink-faint);
--danger: #cf6b6b;
--danger-sunken: #33201f;
/* Marks for projects, groups, labels and filters.
*
* The one set of colours in this file that is *not* redefined per theme, and
* on purpose. Everything else here is text, a ground or a border, and has to
* be measured against whatever sits behind it. These are none of those: they
* are small solid dots beside a label that carries the meaning, so what they
* owe is to be told apart from one another and to be visible on both a
* near-black and a white ground. All of them sit in the mid-tone band where
* that is true, which is why none is very dark or very pale.
*
* It also keeps them out of the trap the themes are in: a colour added to four
* blocks and forgotten in the fifth looks finished and falls back silently.
* There is no fifth block to forget.
*
* The ids are what the database stores. web/src/lib/colors.js lists them for
* the picker, and a Go test reads that file to check the server's list of
* accepted names has not drifted from it. */
--color-graphite: #8a8f98;
--color-tomato: #d9614f;
--color-amber: #d9a04f;
--color-lime: #a3b74d;
--color-green: #4fae7a;
--color-teal: #45b0ab;
--color-blue: #5b9bd9;
--color-indigo: #7f83d6;
--color-violet: #a86fd0;
--color-magenta: #d46aa8;
/* Type. System stack: it renders instantly, matches the platform, and has
proper Danish glyphs on every device — a webfont would cost a round trip
and a flash of unstyled text on the one screen people open fifty times a day. */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', system-ui, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-lg: 1.0625rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
/* Spacing on a 4px grid. */
--s1: 0.25rem;
--s2: 0.5rem;
--s3: 0.75rem;
--s4: 1rem;
--s5: 1.5rem;
--s6: 2rem;
--s7: 3rem;
--s8: 4rem;
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-full: 999px;
/* Shadows are nearly absent by design. A flat surface separated by a hairline
reads as calmer than the same surface floating on a drop shadow. */
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
/* Motion. Fast enough to feel instant, slow enough to be seen. The
complete-animation is the one place a longer curve earns its keep. */
--ease: cubic-bezier(0.2, 0, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--fast: 120ms;
--medium: 200ms;
--slow: 400ms;
--sidebar-width: 268px;
--content-max: 720px;
}
/*
* The dark palette again, by name.
*
* :root carries it as the default, and :root only ever matches <html> — so a
* swatch in the settings picker asking for data-theme="dark" would otherwise
* inherit whatever the page is wearing. Repeated rather than restructured: the
* alternative is splitting the token block in two and leaving somebody to work
* out which half a new token belongs in.
*/
[data-theme='dark'] {
--ground: #111312;
--surface: #171a19;
--surface-raised: #1d211f;
--surface-sunken: #0d0f0e;
--line: #262b29;
--line-strong: #343a37;
--ink: #e8ebe9;
--ink-muted: #9aa39e;
/* The quiet tier, and the one that decides whether the interface is
readable: dates, counts and hints all live here. Lifted from #6b736f,
which sat at 3.8:1 against the ground — under AA, and it showed. */
--ink-faint: #828a84;
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #1c3329;
--accent-ink: #0d1411;
--p1: #cf6b6b;
--p2: #d69f5c;
--p3: #6b9ecf;
--p4: var(--ink-faint);
--danger: #cf6b6b;
--danger-sunken: #33201f;
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
}
/*
* The other themes.
*
* Each one redefines colour and nothing else: no spacing, no type, no radii.
* That is what keeps them themes rather than five separate designs, and it is
* why a component that reaches for a raw hex breaks in four places at once
* instead of one.
*
* Every theme states its full palette rather than inheriting the dark one and
* patching it. A partial theme looks finished until somebody adds a token, at
* which point four themes silently fall back to a colour from a fifth.
*/
/* Skumring — the same dark, turned warm, with amber where the green was. */
[data-theme='dusk'] {
--ground: #14110f;
--surface: #1b1714;
--surface-raised: #221d19;
--surface-sunken: #100d0b;
--line: #2b2521;
--line-strong: #3a322c;
--ink: #ece6df;
--ink-muted: #a3988c;
/* Light enough to clear 4.5:1 against the ground. The quiet tier is where
dates and counts live, and it is the first thing to disappear. */
--ink-faint: #8d8377;
--accent: #d99a4e;
--accent-hover: #e8a95c;
--accent-sunken: #33261480;
--accent-ink: #16110a;
--p1: #cf6b6b;
--p2: #d69f5c;
--p3: #6b9ecf;
--p4: var(--ink-faint);
--danger: #cf6b6b;
--danger-sunken: #33201f;
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
}
/* Papir — warm light, closer to paper than to a screen. */
[data-theme='paper'] {
--ground: #f6f1e7;
--surface: #fffcf6;
--surface-raised: #fffcf6;
--surface-sunken: #ece5d7;
--line: #ded5c3;
--line-strong: #c8bda6;
--ink: #241f18;
--ink-muted: #665c4c;
--ink-faint: #756a55;
--accent: #8a5a2b;
--accent-hover: #74491f;
--accent-sunken: #ebdfcb;
--accent-ink: #fffcf6;
--p1: #b0452f;
--p2: #96701f;
--p3: #3f6b8a;
--p4: var(--ink-faint);
--danger: #b0452f;
--danger-sunken: #f3e2dc;
--shadow: 0 1px 2px rgb(60 40 10 / 0.08);
--shadow-lg: 0 8px 32px rgb(60 40 10 / 0.14);
}
/*
* Kontrast — for reading in bright sun, or for eyes that the quiet greys of the
* other four do not serve. Pure black on white, borders that are actually
* visible, and an accent dark enough to pass against white text.
*
* The muted inks are deliberately much darker than elsewhere: "secondary text"
* at 60% grey is the first thing to disappear, and it is where dates live.
*/
[data-theme='contrast'] {
--ground: #ffffff;
--surface: #ffffff;
--surface-raised: #f2f2f2;
--surface-sunken: #e8e8e8;
--line: #767676;
--line-strong: #1a1a1a;
--ink: #000000;
--ink-muted: #2b2b2b;
--ink-faint: #4a4a4a;
--accent: #005c2e;
--accent-hover: #00753a;
--accent-sunken: #cfe8da;
--accent-ink: #ffffff;
--p1: #a10000;
--p2: #7a4a00;
--p3: #00417a;
--p4: var(--ink-faint);
--danger: #a10000;
--danger-sunken: #f7dcdc;
--shadow: 0 1px 2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
}
[data-theme='light'] {
--ground: #faf9f7;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-sunken: #f2f1ee;
--line: #e5e3de;
--line-strong: #d3d0c9;
--ink: #1c201e;
--ink-muted: #5f6763;
/* Was #8b918d at 3.1:1 — the worst of the five, and the theme most likely
to be read in daylight. */
--ink-faint: #6e756f;
--accent: #257552;
--accent-hover: #1f6a49;
--accent-sunken: #e2f0e9;
--accent-ink: #ffffff;
--p1: #c04a4a;
--p2: #b57a2b;
--p3: #3d7ab5;
--p4: var(--ink-faint);
--danger: #c04a4a;
--danger-sunken: #f7e6e5;
--shadow: 0 1px 2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
background: var(--ground);
color: var(--ink);
font-family: var(--font);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* The app owns its own scrolling regions; the document itself never scrolls,
which is what stops iOS rubber-banding the whole interface. */
overflow: hidden;
overscroll-behavior: none;
}
h1,
h2,
h3,
h4 {
margin: 0;
font-weight: 560;
letter-spacing: -0.015em;
line-height: 1.25;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: none;
border: none;
padding: 0;
}
/* One focus treatment everywhere, and only for keyboard users: a focus ring that
also appears on mouse clicks is noise that people learn to ignore. */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
::selection {
background: var(--accent-sunken);
color: var(--ink);
}
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
dominates a minimal layout. */
* {
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: var(--radius-full);
border: 3px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* Somebody who has asked for less motion gets none of it. The complete-animation
is satisfying; it is not worth making a person nauseous. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}