Skip to main content
“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. 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. 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
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:
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: 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.
Last modified on October 21, 2026