> ## Documentation Index
> Fetch the complete documentation index at: https://skaum.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Design System

> Architectural Curator design language — colors, typography, patterns

"Architectural Curator" design language — warm-professional aesthetic with tonal depth. Full specification: see `DESIGN.md`. Tokens defined in `app/assets/tailwind/application.css` (`@theme` block). Inter is served from the app's own assets in every layout (VOT-443): `app/assets/fonts/InterVariable.woff2` (variable, Latin + Latin Extended subset, SIL OFL 1.1 — `InterVariable-OFL.txt`) declared in `app/assets/stylesheets/fonts.css`; no request goes to Google Fonts.

## Colors

* `primary` `#2f6770` (teal)
* `primary-dim` `#215b63`
* `secondary` `#895213` (ochre)
* `surface-bright` `#fef9f1` (warm cream bg)
* `on-surface` `#1c1a15` (text/headings)
* `on-surface-variant` `#625f55` (secondary text)
* `outline-variant` `rgba(53,50,42,0.15)` (ghost borders)

Surface scale: `surface-lowest` `#ffffff` · `surface-bright` · `surface-low` `#f8f3ea` · `surface-container` `#f1ebe0` · `surface-high` · `surface-dim`.

Legacy aliases (`ft-parchment`, `ft-navy`, `ft-border`) kept in @theme but map to new values.

## Font

`font-display` = Inter (variable, weights 100–900). Applied via `font-display` class on `<body>`.

## Radii

Near-square by default (`0.125rem`). `radius-lg` `0.5rem` · `radius-xl` `0.75rem` · `radius-full` `9999px` (pill).

## Patterns

* Labels/headers/badges/nav: uppercase `text-xs font-bold tracking-wider`. Major headings: `font-black`. The public pages are the exception: their nav and buttons are in sentence case (VOT-477, below).
* Cards: `.s-card` — borderless white (`surface-lowest`) bg, `rounded-xl`. `.s-elevated` for floating cards. `.s-section` for tonal background groups. `.s-section-header` for section labels.
* Buttons: `.btn-primary` — teal gradient pill (`primary` → `primary-dim`), white text, uppercase. `.btn-ghost` — transparent pill with ghost border.
* Badges: small bordered pill-shaped uppercase (`rounded-full`) — green (active), red (closed), gray (preparing). All badges use pill shape.
* `.s-chip` — compact metadata pill badge with `surface-container` bg.
* Decision badges: poll outcomes — green border/bg for adopted (Vedtatt) and elected (Valgt), red for rejected (Avvist), gray for tied (Uavgjort). Includes threshold context when not default simple majority. Helper: `poll_decision_badge(poll)` (light only; the projector has its own pills, below). `poll_vote_percentage(vote_count, poll)` helper computes counting-base-aware percentages.
* Active tab: `border-b-2 border-primary`. Used in sort tabs and other tab strips.
* Secretary shell (VOT-473, «Podium frame»): a 56 px teal top bar and an 80 px rail (the podium, `podium` tokens below) frame a cream work area with rounded inner corners (`--sv-corner`): an 18rem pane (`surface-low`) beside the stage (`surface-bright`). The current rail entry is a tab in the pane's colour that flows into it, with concave corners above and below (`::before` / `::after`; with the pane hidden the tab takes the stage's colour). Rail labels sit under the icons in sentence case; groups are split by a hairline. While preparing, an amber frame (`state-preparing-*`) runs round the stage with its label at the top. Tilpass draws switches (`.sv-switch`) in grouped cards. A page names itself once beside the rail (VOT-474): the pane's title (`.sv-pane-title`) while the pane shows; the stage's heading repeating it carries `.sv-page-name`, visually hidden (still read by screen readers) until the pane is hidden or below 1024 px. A page's sections use `.sv-section-title` (name + count); the menus the shell reuses (avatar menu, view switcher), the self-assign banner and the closed chip are in sentence case there, the application layout keeps its small capitals. Styles only in `app/assets/tailwind/secretary_shell.css`: `.sv-*` classes inside `@layer components`, colours only through tokens, at most one viewport `@media` (the \< 64rem pane rule; the rest container queries on the stage), no uppercase. The Saksliste pane's selected row keeps `.active-sidebar-item` (`surface-container` bg, `rounded-lg`, no left border).
* Focus: teal glow on `:focus-visible` (`box-shadow`), teal border + ring on inputs.
* Layouts: `surface-bright` bg for the app and voter layouts, which have no dark theme. The projector layout paints from its own token layer, light or dark per screen (below).
* Poll results: teal progress bars (`.poll-bar-fill`) on `.poll-bar-track`. Winner state uses `secondary` (ochre).
* `.s-result-card` — teal gradient hero card for prominent results.
* `.custom-scrollbar`: thin scrollbar with `surface-container`/`surface-low` colors.
* Unconfirmed badge: red-bordered pill badge (`border-red-300 bg-red-50 text-red-800`). Muted poll results: `opacity-60` wrapper for unconfirmed polls. Helper: `poll_unconfirmed_badge(poll)`. Chair agenda: half-filled red circle (bottom half `bg-red-200`, `border-red-300`) for items with unconfirmed polls.

## Secretary shell tokens

The secretary shell (VOT-473, `layouts/secretary`) paints its podium — the top bar and the rail — and the amber preparing frame from 13 tokens in `@theme`, fixed hex values that are **never** derived from `--color-primary` / `--color-primary-dim`: an organisation overrides those (`ApplicationHelper#organization_style_tag`), and the podium must stay legible whatever the organisation's colour. The stage keeps the app tokens, so its buttons still follow the organisation.

| Token | Value | Use |
| - | - | - |
| `podium` | `#163b41` | Top bar and rail background |
| `podium-raised` | `#1f4a51` | Hover, the view-switcher trigger, the «Avsluttet» chip |
| `on-podium` | `#eef4f2` | Text on the podium, the tooltip |
| `on-podium-muted` | `#a7c1c1` | Secondary text on the podium |
| `podium-label` | `#d0dfdd` | Rail labels and icons |
| `podium-live` | `#8fd0c3` | Open-poll dot, connection dot, avatar, focus ring on the podium |
| `podium-preparing` | `#f4c46e` | «Forberedelse» chip, «Start møte» pill |
| `podium-lost` | `#ff9f8f` | Lost-connection dot |
| `on-podium-preparing` | `#3d2600` | Text on `podium-preparing` |
| `state-preparing-edge` | `#e9b866` | The amber frame round the stage |
| `state-preparing-bg` | `#fdf0d5` | The frame label's background |
| `state-preparing-ink` | `#6b4100` | The frame label's text |
| `state-preparing-line` | `#b07a1f` | The amber pill's edge on cream |

Contrast targets (WCAG 2.1, `ProjectorTheme.contrast`): text ≥ 4.5:1, non-text (dots, focus rings, pill and chip edges) ≥ 3:1. `test/configuration/secretary_shell_pins_test.rb` pins every pair the shell draws — e.g. `podium-label` on `podium` and `podium-raised`, `on-podium-preparing` on `podium-preparing`, `state-preparing-ink` on `state-preparing-bg`, `podium-live` and `podium-lost` on `podium`, `state-preparing-line` on `surface-bright` and `surface-low` — together with the hex values, the import order (after `question_form.css`) and the stylesheet rules above.

## Public page tokens (VOT-477)

The public site (`/`, the persona pages `/for/sekretariat|ordstyrer|styret`, `/bestill-motestotte`, and the header and footer of `/priser`) speaks in two colours: teal for the product (the `podium` hall, `primary`) and **Lyng** (heather) for everything about the møtestøtte service — the «Bestill møtestøtte» pill, the møtestøtte page and the møtestøtte box on the front page. The Lyng tokens are fixed hex values in `@theme`, never derived from `--color-primary`. Spec: `DESIGN.md` §9.

| Token | Value | Use |
| - | - | - |
| `lyng-hero` | `#ecdde8` | The møtestøtte hero and header (`data-theme="lyng"`), the «Laget av …» band |
| `lyng-ink` | `#34192e` | Text on `lyng-hero`, the møtestøtte box's text |
| `lyng-muted` | `#6e4e66` | Secondary text and nav links on `lyng-hero` |
| `lyng-page` | `#fbf6fa` | The møtestøtte page's background |
| `lyng-accent` | `#7b3a6c` | The «Bestill møtestøtte» pill, the e-mail button, step numbers, links |
| `lyng-accent-hover` | `#652f59` | Hover on `lyng-accent` |
| `lyng-chip` | `#f1e1ec` | Icon chips; the møtestøtte box on the front page |
| `lyng-chip-ink` | `#6a2f5c` | Icons on `lyng-chip` |
| `lyng-band` | `#5e2a52` | The «Klar til å bestille?» band |
| `lyng-portrait` | `#dfc8da` | The photo placeholder |

Every text pair reaches 4.5:1 (`lyng-muted` on `lyng-hero` is the lowest, about 5.5:1). The public-page classes live in `app/assets/tailwind/marketing.css`, imported after `secretary_shell.css`:

* `.site-header[data-theme="dark|light|lyng"]`, `.site-nav-link`, `.site-mega` (the «Slik fungerer det» panel and the «Meny» sheet), `.site-bar` (the desktop actions).
* Pills: `.site-pill` with `.site-pill-cream` («Prøv gratis» on the dark bar), `.site-pill-main` (podium teal elsewhere), `.site-pill-lyng` (outlined when it carries `aria-current="page"` in `.site-bar`), `.site-pill-block` in the sheet.
* Calls to action: `.site-cta` with `.site-cta-cream`, `.site-cta-lyng`, `.site-cta-lyng-inverse` (on `lyng-band`). Headings: `.site-display`, `.site-display-xl`; the rising persona panel: `.site-lift`.
* The hall illustration: `.demo-screen` (16:9, its text in `cqw` of its own width) and `.demo-phone` (the outer box is the container; `data-state="ballot|voted|covered"`, set by `voting_demo_controller.js`). The fill bar animates and the live dot pulses only under `prefers-reduced-motion: no-preference`.
* `.site-faq` and `.site-faq-sign`: the questions' `<details>` without the browser's marker, «+» / «–» instead.

## Projector tokens and the canvas unit

The projector (`layouts/projector`: the live page, the organiser's preview and the overview thumbnails) does not use the app tokens above. `app/assets/tailwind/projector.css` keeps every rule in `@layer components`, uses only `.pj-*` classes and has its own colour tokens, canvas unit and type tiers (VOT-446). Its header comment and `test/configuration/projector_legibility_pins_test.rb` pin the rules below.

### Two token sets

Every colour the projector paints is one of 46 `--pj-*` tokens, declared twice with the same names: the light set on `.pj-body` and the dark set on `.pj-body[data-theme="dark"]`. The body carries the screen's `theme` as `data-theme`, so the set reaches the canvas, every band, region and pill, the cue and the rehearsal frame. Every value is a `#rrggbb` literal. The groups:

* Surfaces and ink: `--pj-canvas`, `--pj-region`, `--pj-region-primary`, `--pj-ink`, `--pj-ink-muted`
* Accent: `--pj-accent` (bar fill, current agenda row, voted cell, initials tile), `--pj-accent-text` (labels, numbers, «Neste:»), `--pj-on-accent`, `--pj-accent-number`
* Results: `--pj-winner`, `--pj-neutral-fill` (muted and eliminated bars), `--pj-track`, `--pj-check-done`
* Cells and dots: `--pj-cell`, `--pj-cell-ink`, `--pj-cell-absent-border`, `--pj-dot-connected`, `--pj-dot-lost`
* Bands: `--pj-warning-{bg,ink,ink-muted,border}` («Prøvevotering», the pause band, the rehearsal frame), `--pj-message-{bg,ink}`
* Pills: `--pj-pill-<tone>-{bg,fg,border}` for every tone of `ProjectorHelper::PILL_TONES`
* `--pj-logo-plate`: the light plate the organisation logo sits on, in both themes

| Token | Light | Dark |
| - | - | - |
| `--pj-canvas` | `#fef9f1` | `#1c1a15` |
| `--pj-region` / `--pj-region-primary` | `#f1ebe0` / `#ffffff` | `#27241e` / `#312d26` |
| `--pj-ink` / `--pj-ink-muted` | `#1c1a15` / `#4b4842` | `#fef9f1` / `#d2cbbd` |
| `--pj-accent` / `--pj-accent-text` | `#2f6770` / `#27585f` | `#7cb8bf` / `#9ed0d6` |
| `--pj-winner` | `#895213` | `#e0a65b` |

Outside the two blocks a colour declaration reads a `var(--pj-…)` token (or `transparent`, `currentColor`, `inherit`, `none`) — never an app token (`--color-*`), a literal or a Tailwind colour utility inside `.pj-canvas`. Nothing is de-emphasised with `opacity`: eliminated options, withdrawn ballot options and a muted («Ikke bekreftet») result use `--pj-ink-muted` and `--pj-neutral-fill`. `opacity` appears only on images and icons and inside the reduced-motion query. The dark block declares colours only — no transition or animation.

The QR code keeps its own white tile and dark modules in both themes. The refusal pages (`layouts/projector_static`) render no `.pj-body` and stay light with the app's utilities.

### Contrast targets

WCAG 2 contrast ratios, per theme:

* the primary ink on the canvas and both regions: at least 7:1;
* every other text pair (muted ink, accent text, the current row's text and number on the accent, every pill's text, the message and warning bands, the cells): at least 4.5:1;
* the bar fills (accent, winner, neutral) on the track: at least 4.5:1;
* every other indicator (pill borders, the rehearsal frame, the connection dots, the voted cell, the absent cell's border, the checks, the initials tile): at least 3:1.

`test/configuration/projector_contrast_test.rb` parses both blocks out of `projector.css` and checks the 63 pairs for each theme; a failing pair is reported with its ratio, a missing token by name. `PROJECTOR_CONTRAST_REPORT=1` prints the three lowest pairs per theme. The system tests check the rendered page too (`ProjectorSystemHelper#assert_text_contrast`: every visible text against the nearest opaque background, light and dark).

### The organisation colour, guarded

The organisation's primary colour (`Organization#effective_branding[:primary_color]`) reaches the projector only through `ProjectorTheme.brand_accent(color, theme)` (`app/models/projector_theme.rb`). It is used when, against that theme's tokens, it reaches 4.5:1 as text on the canvas and both regions, 4.5:1 as a fill on the track, and white or `#1c1a15` reaches 4.5:1 on it (the better of the two becomes the text on the accent). Otherwise the theme's own accent applies. The teal `#2f6770` passes in light and fails in dark; a light yellow passes only in dark.

`ProjectorHelper#projector_brand_style_tag(organization, theme)` turns a passing colour into the layout's one head `<style data-turbo-track="dynamic">`, which sets only `--pj-accent`, `--pj-accent-text`, `--pj-on-accent` and `--pj-accent-number` on `.pj-body`. It is unlayered, so it beats the token blocks, and `data-turbo-track="dynamic"` lets Turbo drop it when a refresh no longer emits it (a screen switched to dark). `ProjectorTheme::GUARD_TOKENS` holds the surface and accent values the guard measures against, pinned equal to the CSS blocks. The projector layout does not call `organization_style_tag`; the admin views and the refusal pages keep it.

### The canvas unit

`layouts/projector` renders `<html class="pj-root">`. Its root rule is the canvas unit:

```css theme={null}
.pj-root {
  --pj-root-vw: 100vw;
  --pj-root-vh: 100vh;
  font-size: max(8px, min(var(--pj-root-vw) / 120, var(--pj-root-vh) / 67.5));
}
```

That is 16 px at 1920×1080 (the reference canvas), 12 px at 1440×900 (the 1920×1200 composition), 10.667 px at 1280×720 and never below 8 px. The viewport lengths sit in custom properties because Tailwind v4's minifier folds `100vw / 120` into `.833333vw`, a root of 15.99999 px that rounds every rem length down. No other page has the class, so every other layout keeps the browser's root size.

Every other length in `projector.css` is in `rem`, `em`, `%`, `fr`, `cqi`, `cqb` or unitless, and `@container` conditions are in `rem`: the whole composition scales with the window. The only `px` are the root's 8 px floor and borders of at most 2 px; the only viewport units are the root rule, `height: 100vh` on the body and the canvas, and the organiser's preview note. Round shapes use `999rem` radii. Browser zoom does not enlarge the canvas; the screen's `text_scale` does (`--pj-scale`: 1, 1.15, 1.3).

### Type tiers

Sizes at the reference canvas, each `calc(<n>rem * var(--pj-scale))` — × k off the reference (k = the root size ÷ 16 px) and × the text scale:

| Tier | Size | Members |
| - | - | - |
| Display | 4rem (64 px); 2.5rem in `compact` / `band` regions | item title, the record's title (heading size in a `medium` region), «Velkommen», idle and «Votering pågår» headings, the pause title |
| Heading | 2.5rem (40 px) | poll title, candidates line, the cue's «Neste:» line, Fritekst, the welcome lead |
| Body | 2rem (32 px) | text, prose, bar rows, the rule and margin lines, agenda and record rows, band text, turnout, the earlier-eliminated list, the ballot |
| Secondary | 1.75rem (28 px) | labels, pill text, the cue meta, «Hurtigvotering er sperret» |
| Grid cell | 1.5rem (24 px) | participant-grid numbers only |
| Key tally | by size hint, capped at `18cqi` | 7rem at `large` / `full`, 4.5rem at `medium`, 3.5rem at `compact` / `band`; the count-only tally (`pj-tally--solo`) 11rem at `large` / `full` |

No text renders below 1.5rem (24 px × k × scale). A pill is at least 2.5rem tall (40 px) with a 2 px border; the hall's bar track is 1.75rem, the Dirigentskjerm's live bars 1rem. Every figure that changes while a screen is open uses tabular numerals. No font size sits inside a container query or behind `:has(`. How regions drop whole blocks instead of cutting them: see `pj-fit` / `pj-fill` / `pj-lines` / `pj-stack` in [Controllers and Partials](/utvikling/controllers-og-partials).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.