Lay the Almanacka design foundation: tokens, type, shell

New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 18:25:04 +02:00
parent 95444d3bf4
commit d14f86478a
44 changed files with 1358 additions and 485 deletions
+164 -130
View File
@@ -3,112 +3,144 @@
@import './components.css';
@import './responsive.css';
/* Typeface — Schibsted Grotesk (OFL), self-hosted from /public/fonts so no
* request ever leaves for a font CDN. Two subsets with Google's own
* unicode-ranges: the browser only fetches latin-ext when a glyph needs it.
* Variable weight axis 400–900; `swap` so text is never invisible while the
* file loads (index.html preloads the latin file to keep that swap short). */
@font-face {
font-family: 'Schibsted Grotesk';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/SchibstedGrotesk-latin-ext-v7.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Schibsted Grotesk';
font-style: normal;
font-weight: 400 900;
font-display: swap;
src: url('/fonts/SchibstedGrotesk-latin-v7.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Design Tokens
*
* One neutral ground + one accent; every other colour is semantic (danger/
* warning/success) and exists as a solid + "-weak" tint pair. Everything
* below is used app-wide, including the calendar and modal families that
* used to hardcode their own hex values — see calendar.css/period-popover.css/
* etc. Two values are load-bearing for e2e: --c-danger-weak (red day
* background) and --c-weekend-bg (plain-Saturday background) — read
* directly by frontend/e2e/tests/red-days.spec.ts. Do not repurpose either.
* One neutral ground, one accent. Hierarchy comes from type and whitespace;
* hairlines only where a boundary is unavoidable; one shadow, only on
* floating layers. Leave types are told apart by fill / ink / outline, not
* by hue, and day states by the numeral, not by a cell fill.
*
* Four names are load-bearing for e2e, which resolves them by name and
* compares against painted elements: --c-danger-weak (red-day cell),
* --c-weekend-bg (plain Saturday), --c-accent-hover and --c-surface
* (header-nav-hover). Do not repurpose any of them.
*/
:root {
/* Color - calm, trustworthy palette */
--c-bg: #f7f8fa;
--c-surface: #ffffff;
--c-border: #e3e6eb;
--c-text: #1c2733;
--c-text-muted: #5b6675;
--c-text-faint: #94a0ac;
--c-accent: #2f6f4f; /* calm green — the app's one accent */
--c-accent-weak: #e8f1ec;
--c-accent-hover: #1f5a3b;
--c-accent-active: #0f4a2f;
--c-danger: #b3261e;
--c-danger-weak: #fdeceb; /* also the red-day cell background — see note above */
--c-danger-hover: #8a1a15;
--c-success: #1e7d4f;
--c-success-weak: #e7f4ec;
--c-success-hover: #0d6a37;
--c-warning: #8a6d1f;
--c-warning-weak: #fef8e7;
--font-sans: 'Schibsted Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
/* Calendar-specific semantics (klämdag/weekend/red-day borders + hover) —
* kept distinct from the generic danger/warning pair above because a red
* day and a klämdag are both visible on screen at once and need to read
* as different hues, not different intensities of the same one. */
--c-weekend-bg: #f0f0f0; /* plain Saturday — read directly by red-days.spec.ts */
--c-weekend-border: #cfcfcf;
--c-weekend-hover: #e8e8e8;
--c-redday-border: #d9776e;
--c-redday-hover: #fbdedb;
--c-klamdag-bg: #fef8e7;
--c-klamdag-border: #d9b23c;
--c-klamdag-hover: #fcf0c8;
/* The one deliberately loud color in the app — a "this just changed"
* flash ring on a calendar band, never a static/resting state. */
--c-flash: #ffd54f;
/* Neutrals */
--c-bg: #fcfcfd;
--c-surface: #fff;
--c-border: #ececf0;
--c-text: #111318;
--c-text-muted: #5e6472;
--c-text-faint: #9297a3;
--overlay: rgba(16, 24, 40, 0.5);
/* The one accent — CTA, focus, active nav, today, selection */
--c-accent: #2f5bea;
--c-accent-weak: #eef2fe;
--c-accent-hover: #2549c4;
--c-accent-active: #1d3ca3;
/* Translucent tints used only for subtle hover/shadow/scrim effects — kept
* as tokens (rather than inlined rgba() elsewhere) because the style
* guard test (styles.test.ts) forbids rgba() outside this file. */
--c-row-hover: rgba(47, 111, 79, 0.02);
--c-accent-tint: rgba(47, 111, 79, 0.04);
--c-accent-shadow: rgba(47, 111, 79, 0.12);
--c-scrim-strong: rgba(0, 0, 0, 0.4);
--c-band-shadow: rgba(0, 0, 0, 0.1);
--c-label-text-shadow: rgba(0, 0, 0, 0.35);
--c-fill-light: rgba(255, 255, 255, 0.35);
--c-badge-fill: rgba(255, 255, 255, 0.3);
--c-scrim-soft: rgba(0, 0, 0, 0.2);
/* Semantic */
--c-danger: #d03a2f;
--c-danger-weak: #fbeceb;
--c-danger-hover: #b02f26;
--c-warning: #b26a00;
--c-warning-weak: #fdf3e3;
--c-success: var(--c-accent);
--c-success-weak: var(--c-accent-weak);
--c-success-hover: var(--c-accent-hover);
/* Spacing scale (8px base) */
/* Day states — a colour on the numeral, never a cell fill */
--c-day-red: var(--c-danger);
--c-day-klamdag: var(--c-warning);
--c-day-saturday: var(--c-text-faint);
--c-weekend-bg: var(--c-surface); /* legacy name; Saturday has no fill */
/* Leave types: fill / ink / outline */
--c-leave-vacation: #eef2fe;
--c-leave-vacation-ink: #2f5bea;
--c-leave-sjukpenning: #2a2d35;
--c-leave-sjukpenning-ink: #fff;
--c-leave-lagsta: #fff;
--c-leave-lagsta-ink: #2a2d35;
--c-leave-lagsta-outline: #2a2d35;
--c-leave-lagsta-hatch: rgba(42, 45, 53, 0.35);
--c-band-gap: #f1f2f5;
--c-band-cap-self: var(--c-accent);
--c-band-cap-partner: #9297a3;
/* Interaction chrome */
--c-flash: var(--c-accent);
--overlay: rgba(17, 19, 24, 0.4);
--c-focus-ring: rgba(47, 91, 234, 0.35);
--c-row-hover: rgba(47, 91, 234, 0.04);
/* Spacing */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-8: 48px;
--sp-7: 48px;
--sp-8: 64px;
/* Type scale */
--fs-5xs: 0.5rem;
--fs-4xs: 0.5625rem;
--fs-2xs: 0.6875rem;
/* Type scale 12/14/16/20/28/40 — nothing below 12px anywhere */
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-lg: 1.25rem;
--fs-xl: 1.5rem;
--fs-2xl: 2rem;
--fs-xl: 1.75rem;
--fs-2xl: 2.5rem;
--lh: 1.5;
--lh-tight: 1.15;
/* Shape & depth */
--radius-sm: 6px;
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-full: 999px;
/* Matches frontend/e2e/tests/calendar-band.spec.ts's literal 4px/0px
* assertions on band corners — kept as its own token (not tied to
* --radius-sm) so a future radius change there can't silently break it. */
* assertions on band corners — kept as its own token so a future radius
* change elsewhere can't silently break it. */
--radius-band: 4px;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08);
--shadow-float: 0 8px 24px rgba(17, 19, 24, 0.1);
/* Content width */
/* Sizes */
--maxw: 960px;
/* Interaction chrome shared by every focusable control */
--c-focus-ring: rgba(47, 111, 79, 0.32);
--fs-3xs: 0.625rem;
--control-h: 40px;
--control-h-sm: 32px;
--icon-sm: 16px;
--icon-md: 20px;
--icon-lg: 24px;
/* Icon glyph sizes (spinner, etc.) — a separate scale from --fs-* since
* these size a single glyph, not running text. */
--icon-lg: 48px;
--icon-md: 36px;
/* Layers */
--z-header: 100;
--z-popover: 900;
--z-modal: 1000;
/* The one inline image in the system: a 1.5px chevron for native selects.
* Lives here (not in a feature file) so the style guard can keep `url(`
* and hex out of everything else. Colour = --c-text-muted. */
--icon-chevron-down: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 8"><path d="M1 1l5 5 5-5" stroke="%235e6472" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>');
color-scheme: light;
}
@@ -124,8 +156,6 @@ html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
@@ -138,9 +168,7 @@ blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
@@ -153,16 +181,12 @@ q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
@@ -183,9 +207,7 @@ th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
@@ -193,49 +215,54 @@ header,
hgroup,
main,
mark,
meter,
nav,
output,
progress,
section,
summary,
time,
video {
time {
margin: 0;
padding: 0;
border: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--c-bg);
color: var(--c-text);
line-height: var(--lh);
font-size: var(--fs-base);
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Headings — tighter leading than body text, the usual editorial cue that a
* line is a heading rather than another sentence of the paragraph below it. */
body {
font-family: var(--font-sans);
font-size: var(--fs-base);
line-height: var(--lh);
background: var(--c-bg);
color: var(--c-text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Headings — tighter leading and a touch of negative tracking at 28px+ is
* what makes a line read as a heading, not a heavier weight. */
h1 {
font-size: var(--fs-2xl);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.01em;
font-size: var(--fs-xl);
font-weight: 600;
line-height: var(--lh-tight);
letter-spacing: -0.02em;
margin-bottom: var(--sp-4);
}
h2 {
font-size: var(--fs-xl);
font-weight: 700;
font-size: var(--fs-lg);
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
margin-bottom: var(--sp-3);
}
h3 {
font-size: var(--fs-lg);
font-size: var(--fs-base);
font-weight: 600;
line-height: 1.3;
margin-bottom: var(--sp-3);
margin-bottom: var(--sp-2);
}
/* Text */
@@ -297,10 +324,10 @@ button:disabled {
/* Inputs & Selects
*
* Matches `.field__control` in primitives.css (same border/radius/padding/
* focus formula) so an unclassed input still looks right. Checkboxes and
* Matches `.field__control` in primitives.css (hairline, 40px, 2px accent
* ring on focus) so an unclassed input still looks right. Checkboxes and
* radios are excluded — they keep their native intrinsic size and just pick
* up the accent color, not the text-field chrome. */
* up the accent color. */
input:not([type='checkbox']):not([type='radio']),
select,
textarea {
@@ -308,12 +335,23 @@ textarea {
min-height: var(--control-h);
font-family: inherit;
font-size: var(--fs-base);
line-height: 1.25;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
background: var(--c-surface);
color: var(--c-text);
transition: border-color 0.2s, box-shadow 0.2s;
transition: border-color 0.15s, box-shadow 0.15s;
}
select {
appearance: none;
padding-right: var(--sp-6);
background-image: var(--icon-chevron-down);
background-repeat: no-repeat;
background-position: right var(--sp-3) center;
background-size: 12px 8px;
cursor: pointer;
}
input:focus,
@@ -321,26 +359,28 @@ select:focus,
textarea:focus {
outline: none;
border-color: var(--c-accent);
box-shadow: 0 0 0 3px var(--c-focus-ring);
box-shadow: 0 0 0 2px var(--c-accent);
}
/* Same crisp keyboard-focus outline as `.field__control:focus-visible`
* (primitives.css) for an unclassed input/select/textarea — see that rule's
* comment. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
outline: none;
}
input:disabled,
select:disabled,
textarea:disabled {
background-color: var(--c-bg);
color: var(--c-text-muted);
cursor: not-allowed;
}
input::placeholder,
textarea::placeholder {
color: var(--c-text-faint);
}
input[type='checkbox'],
input[type='radio'] {
width: auto;
@@ -348,11 +388,8 @@ input[type='radio'] {
cursor: pointer;
}
/* Number inputs — hide the native up/down spinner everywhere. It doubles
* the field's effective click target with no matching `.field__control`
* affordance and its width isn't accounted for by any of this app's `ch`-
* based sizing. Callers still get numeric-only mobile keyboards via
* `inputmode="numeric"` on the element itself. */
/* Number inputs — hide the native up/down spinner everywhere. Callers still
* get numeric-only mobile keyboards via `inputmode="numeric"`. */
input[type='number'] {
appearance: textfield;
}
@@ -363,9 +400,6 @@ input[type='number']::-webkit-inner-spin-button {
margin: 0;
}
/* Date inputs — same height/padding/font as `.field__control` by default
* (the shared `input:not([type=checkbox]):not([type=radio])` rule above
* already covers it), just with a visibly clickable calendar affordance. */
input[type='date']::-webkit-calendar-picker-indicator {
opacity: 0.6;
cursor: pointer;
@@ -379,7 +413,7 @@ label {
color: var(--c-text);
}
/* Tables */
/* Tables — no header fill; one hairline per row. */
table {
width: 100%;
border-collapse: collapse;
@@ -387,15 +421,15 @@ table {
th {
text-align: left;
font-weight: 600;
padding: var(--sp-3) var(--sp-4);
background-color: var(--c-bg);
font-weight: 500;
font-size: var(--fs-sm);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--c-border);
color: var(--c-text-muted);
}
td {
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--c-border);
}