`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>
421 lines
11 KiB
CSS
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;
|
|
}
|