SPEC-DES-001: Design System and Visual Identity
| Reference Number | Version | Effective Date | Supersedes | Authority Level |
|---|---|---|---|---|
| SPEC-DES-001 | 2.5 | 2026-10-24 | FEAT-DES-001 v1.1 (archived) |
Normative |
Supersedes
docs/archive/v1-docs/FEATURES/DESIGN/FEAT-DES-001_Design_System.md(archived, non-normative). That document described the v1 token set and carries values that no longer match the implementation; it governs nothing and is retained as history only.
I. Purpose
This specification defines the canonical visual identity of the Classroom Token Hub: the design token architecture, the role-theme model, the semantic usage rules that bind templates to tokens, and the conformance criteria by which a surface is judged to be on-system.
It exists so that visual consistency is a checkable contract rather than a matter of per-template taste, and so that a color, space, or type decision has exactly one place it can legitimately be made.
II. Scope
Applies to:
static/css/tokens.css— the token layer, and the sole source of truth for design valuesstatic/css/style.css— the component and utility layerstatic/css/fonts.css— the typeface layer- all Jinja templates under
templates/ - template-embedded
<style>blocks and inlinestyleattributes - any CSS that affects rendered color, spacing, typography, elevation, or motion
Does not govern:
- page rendering structure, view models, or builder organization — governed by
SPEC-UI-001 - accessibility requirements — governed by
INV-ARC-020, which this document is subordinate to and must not weaken github-pages/,docs-generator-proof/anddocs-site/— separately published artifacts. See §XIII.- domain, mutation, or persistence authority
III. Authority Level
Normative (Tier 2). Subordinate to INV-CORE-000, INV-ARC-020, and INV-ARC-022.
Per SOP-DOC-000 §V, this specification is a technical contract. Its requirements bind implementation where incorporated by the applicable INV-* or DOM-* contract. Where a requirement here conflicts with INV-ARC-020, the invariant wins.
IV. Dependencies
docs/INVARIANT/CORE/INV-CORE-000_CORE_INVARIANTS.mddocs/INVARIANT/ARCHITECTURE/INV-ARC-020_ACCESSIBILITY_REQUIREMENTS_AND_TEMPLATE_CONTRACT.mddocs/INVARIANT/ARCHITECTURE/INV-ARC-022_REQUEST_CONTEXT_AND_PAGE_RENDERING.mddocs/SPEC/SPEC-UI-001_PAGE_RENDERING_SPECIFICATION.mddocs/STANDARD_OPERATING_PROCEDURES/SOP-DOC-000_DOCUMENTATION_STANDARD.md
V. Design Philosophy
The interface serves three audiences from one codebase. A teacher administering an economy, a student participating in it, and a system administrator operating it are looking at the same structures with different stakes. The design system encodes that as one layout vocabulary, three color identities.
1. Core Principles
-
Context-aware roles. The same markup renders in the active role’s identity. The role is carried by a single body class; nothing downstream re-decides it.
Role Identity Character Teacher (default) Advisor Green Professional, authoritative, warm Student ( body.student-shell)Steward Blue Trustworthy, calm, focused Sysadmin ( body.sysadmin-shell)Guardian Gray Neutral, technical, high-contrast -
Semantic over literal. A template names the role a value plays, never the value itself.
var(--danger), not#c0392b. -
Minimal and flat. Hierarchy comes from spacing, type weight, and restrained borders — not from heavy shadow or gradient. Elevation is reserved for genuine overlay.
-
One definition per decision. A design value is defined once, in the token layer. A value that appears literally in two places is drift that has not yet been noticed.
2. The Role-Theme Contract
Role theming is achieved only by token reassignment under a body class. Consequences that are binding:
- A template MUST NOT branch on role to pick a color, class, or asset.
- A component MUST NOT be duplicated per role.
- A role theme MUST define the complete theme-layer token set (§VI.3). A theme that omits a token inherits a value designed for a different identity, which is a defect, not a fallback.
VI. Token Architecture
Tokens live in static/css/tokens.css in two layers.
1. Base Layer — role-agnostic
Defined on :root, identical in every role.
| Category | Tokens | Notes |
|---|---|---|
| Typeface | --font-family, --font-display, --font-data |
Inter / Atkinson Hyperlegible Next / IBM Plex Mono |
| Type scale | --text-2xs … --text-4xl, --leading-*, --weight-*, --tracking-* |
§VI.2 |
| Spacing | --space-0 … --space-16 |
§VI.2 |
| Radius | --radius-xs (2px), --radius-sm (4px), --radius-md (6px), --radius-lg (8px), --radius-xl (12px), --radius-pill (999px) |
sm cards/inputs, md buttons, lg modals, xl feature panels. The auth card is square — see §IX |
| Elevation | --shadow-sm, --shadow-md, --shadow-lg |
Overlay only; not decoration |
| Motion | --duration-fast/base/slow, --ease-standard |
§VI.2 |
| Opacity | --alpha-subtle/soft/medium/strong |
§VI.2 |
| Icon size | --icon-2xs … --icon-3xl |
§VI.2 |
| Layout | --sidebar-width, --breakpoint-* |
Structural, role-agnostic |
| Neutrals | --neutral-50 … --neutral-900 |
Warm-tinted grayscale |
| Surfaces | --background, --surface, --border-color |
--background is --neutral-100 |
| Text | --text-primary, --text-secondary, --text-muted, --text-inverse |
|
| Contrast-pinned text | --accent-text-on-light, --alert-success-text, --alert-warning-text, --alert-danger-text, --alert-info-text, --text-on-dark-muted |
§VI.6 |
2. Required Scales
The v1 system defined color and radius but left spacing, typography, motion, opacity, and icon sizing untokenized. Measured cost at the time of this revision: 25 distinct font sizes, 19 spacing values, 20+ opacity values, and 6 transition durations hardcoded across style.css, plus ~180 inline font-size declarations on icons in templates. These scales are therefore normative.
Spacing — 4px base, geometric after --space-4:
--space-0 0 · --space-1 4px · --space-2 8px · --space-3 12px · --space-4 16px · --space-5 20px · --space-6 24px · --space-8 32px · --space-10 40px · --space-12 48px · --space-16 64px
Type scale — rem-based, 16px root:
--text-2xs 0.6875rem · --text-xs 0.75rem · --text-sm 0.875rem · --text-base 1rem · --text-lg 1.125rem · --text-xl 1.25rem · --text-2xl 1.5rem · --text-3xl 1.875rem · --text-4xl 2.5rem · --text-5xl 3rem · --text-6xl 4rem
--text-5xl and --text-6xl are display sizes — a status code or a single hero figure — and never set running text. Text MUST NOT borrow an --icon-* token because the two scales happen to share a value: they are named for different jobs and are free to diverge.
Weights --weight-normal 400 · --weight-medium 500 · --weight-semibold 600 · --weight-bold 700.
Line heights --leading-tight 1.25 · --leading-snug 1.4 · --leading-normal 1.6.
Tracking --tracking-tight -0.01em · --tracking-normal 0 · --tracking-wide 0.05em · --tracking-wider 0.1em · --tracking-widest 0.5em. --tracking-widest is reserved for PIN and recovery-code inputs, where the gap is what makes a digit group countable.
Motion — --duration-fast 120ms · --duration-base 200ms · --duration-slow 300ms · --ease-standard cubic-bezier(0.2, 0, 0.2, 1).
All motion MUST remain subject to the prefers-reduced-motion override in §XI.
Opacity — --alpha-subtle 0.08 · --alpha-soft 0.16 · --alpha-medium 0.35 · --alpha-strong 0.6.
Icon size — --icon-2xs 0.875rem · --icon-xs 1rem · --icon-sm 1.125rem · --icon-md 1.25rem · --icon-lg 1.5rem · --icon-xl 2rem · --icon-2xl 3rem · --icon-3xl 4rem (empty-state and hero glyphs only).
Icon sizing MUST be applied through the .icon-* utility classes, never through an inline font-size.
Token utilities. So that a template can reach a token without an inline style attribute, style.css provides exactly one class per token: .type-2xs … .type-6xl (font size), .icon-2xs … .icon-3xl, .tracking-wide / -wider / -widest, .alpha-subtle / -soft / -medium / -strong, and .text-accent for gold used as legible text. A utility MUST restate no value; it references its token.
3. Theme Layer — role-specific
Every role theme MUST define the following complete set. Absence is a defect.
| Token | Purpose |
|---|---|
--primary, --primary-hover, --primary-subtle |
Brand and main interaction |
--secondary, --secondary-hover, --secondary-text, --secondary-subtle |
Accent |
--accent |
Emphasis alias |
--success, --success-hover, --success-subtle |
Status |
--info, --info-hover, --info-subtle |
Status |
--warning, --warning-hover, --warning-subtle |
Status |
--danger, --danger-hover, --danger-subtle |
Status |
--bs-primary … --bs-danger |
Bootstrap bridge (§VII) |
--bs-primary-rgb … --bs-danger-rgb |
Bootstrap bridge (§VII) |
4. Canonical Brand Values
These are the authoritative values. Where any other artifact disagrees, that artifact is wrong.
| Token | Teacher | Student | Sysadmin |
|---|---|---|---|
--primary |
#1a4d47 |
#2F4F7F |
#303030 |
--primary-hover |
#15403b |
#253f66 |
#1a1a1a |
--primary-subtle |
#e8f0ef |
#e8edf4 |
#e8e8e8 |
--secondary |
#D4A857 |
#D4A857 |
#D4A857 |
--secondary-hover |
#c09840 |
#c09840 |
#c09840 |
--secondary-text |
--neutral-900 |
--neutral-900 |
--neutral-900 |
The secondary triplet is role-agnostic, and that is load-bearing.
--secondaryand--secondary-textare consumed together by shared components —.bg-secondary,.btn-secondary, and every badge built on them — so the pair has to satisfy §XI in whichever theme happens to be mounted. It cannot be satisfied per-role by accident. Gold is a fill: it carries--neutral-900at 8.71:1 and white at 2.12:1. A role theme therefore MUST NOT set a light--secondary-text, and MUST NOT darken--secondary-hoverpast the point where--secondary-textstill clears AA on it —#c09840holds at 6.85:1, a brown such as#725132collapses to 2.58:1.Naming note. v1 documented the teacher accent as
#d3af37and the student accent as bronze#ac8255, and the student theme long carried a brown--secondary-hoverwith white text to match. All three roles have since converged on a single gold; the v1 values and the brown hover are superseded. Only--secondary-subtlestill differs by role, and it is a background fill that never pairs with a pinned foreground. The label “Bronze” no longer describes the student accent.
5. Adding a Token
- Role-agnostic? Add to
:root. - Role-dependent? Add to all three theme blocks (§VI.3).
- Name it
--{category}-{property}-{modifier}— e.g.--chart-primary-fill. - A token whose value is a literal duplicate of an existing token MUST instead alias it.
- Name a token for the surface it is legible on, not for the first page that used it.
6. Contrast-Pinned Text Tokens
A brand token and a legible text token are not the same thing. A role theme is free to pick any --secondary-hover that reads well as a button fill; that same value is usually unusable as body text on white. Where the two requirements collide, the text value MUST be pinned in a separate role-agnostic token rather than inherited from the theme.
| Token | Pinned against | Why the theme token fails |
|---|---|---|
--accent-text-on-light #735816 |
--surface / white |
Sysadmin --secondary-hover #c09840 is 2.69:1 — fails even the 3:1 large-text threshold. Pinned value is 6.70:1. |
--alert-success-text #1f5c42 |
--success-subtle |
--success #2e7d5b is 4.41:1 on --success-subtle, below AA for body-size text. Pinned value is 6.94:1. |
--alert-warning-text #5f3d00 |
--warning-subtle |
--warning #D4A857 is a fill, not a text color |
--alert-danger-text #8f1f1f |
--danger-subtle |
ditto |
--alert-info-text #1f4f73 |
--info-subtle |
ditto |
--text-on-dark-muted #DEDEDE |
Dark hero / sidebar | The neutral ramp is tuned for dark-on-light and inverts poorly |
These are deliberately not per-role. A per-role definition would reintroduce exactly the failure it exists to prevent: the theme picking a value that happens to fail in one role only, silently, on one page.
A decorative, aria-hidden icon is exempt and MAY use the theme token directly.
VII. The Bootstrap Bridge
Bootstrap is themed by remapping its variables onto project tokens. Two rules are binding because both have been violated in practice:
-
RGB parity. Every
--bs-*-rgbMUST be the decimal decomposition of the hex its matching--bs-*token resolves to. These drift silently: nothing errors when they disagree, the UI just renders a stale color in anyrgba()context. -
Bridge completeness. A theme that defines
--bs-secondarybut omits--bs-secondary-rgbwill fail silently wherever a component composesrgba(var(--bs-secondary-rgb), α)— the declaration is invalid and the property falls back. Every theme MUST define the full RGB set.
Per INV-ARC-020 §IX.4, Bootstrap defaults MUST NOT be assumed to satisfy contrast once combined with project tokens.
VIII. Semantic Usage Rules
1. Backgrounds
Use the subtle tokens for containers, alerts, and sections. Opacity hacks are prohibited.
| Use case | Correct | Prohibited |
|---|---|---|
| Page background | var(--background) |
#f5f5f3, bg-light |
| Card surface | bg-surface |
bg-white |
| Success alert | bg-success-subtle |
bg-success bg-opacity-10 |
| Primary container | bg-primary |
style="background-color: {{ theme_color }}" |
2. Text
| Use case | Class / token |
|---|---|
| Primary action | text-primary |
| Body | text-body / var(--text-primary) |
| Muted / meta | text-muted / var(--text-secondary) |
| Numeric / ledger data | var(--font-data) |
3. Buttons
Standard Bootstrap buttons, themed by the bridge. .btn-primary, .btn-secondary, .btn-danger MUST resolve their colors from --primary / --secondary / --danger. A component rule that hardcodes a color defeats the theme it sits inside.
4. Icons
Material Symbols Outlined only. No Bootstrap Icons, no Font Awesome. Size via .icon-* (§VI.2).
Per INV-ARC-020 §VII.B, an icon-only control requires an aria-label.
5. Borders
border uses --border-color. For semantic emphasis use border-primary, border-danger.
IX. Template Contract
Templates render tokens; they do not define design values.
A template MUST NOT contain:
- A hardcoded color in any form — hex,
rgb(),hsl(), or a named CSS color — in markup, in a<style>block, or in template-embedded JavaScript. - An inline
styleattribute carrying a static presentational value. Static means the declaration is identical on every render. - A
<style>block whose rules are not specific to that single page. - A role conditional that selects styling (§V.2).
A template MAY contain:
- An inline
styleattribute whose value is computed per render — e.g.style="width: {{ pct }}%"on a progress bar. This is the legitimate use of the attribute and is not a violation. The dynamic portion MUST still reference tokens for any non-computed part. - A page-scoped
<style>block for genuinely single-use layout, provided every design value in it resolves to a token.
Design values versus structural values
The token requirement above governs design values only. A design value is one the design system has an opinion about and could change system-wide:
| Category | Token family |
|---|---|
| Color | --primary, --surface, --text-*, --*-subtle, status colors |
| Type size, weight, leading, tracking | --text-*, --weight-*, --leading-*, --tracking-* |
| Spacing — padding, margin, gap | --space-* |
| Radius | --radius-* |
| Elevation | --shadow-* |
| Motion — duration, easing | --duration-*, --ease-standard |
| Opacity | --alpha-* |
| Icon size | --icon-* |
A structural value describes the geometry of one specific element and has no system-wide meaning. min-height: 100dvh on a full-viewport page, width: 0% on a meter that script drives to its real width, border-radius: 50% to make a square a circle, grid-template-columns: 1fr 1fr auto, max-width: 200px capping a QR image, border-left: 4px solid var(--border-color) — the number in each is a fact about that element, not a design decision the system owns. Tokenizing them would invent tokens with one call site each and make the scale meaningless.
Structural values are permitted as literals. A value is structural only if changing the corresponding token everywhere would not be expected to change it; when in doubt, it is a design value.
Shared-surface rule
CSS repeated across templates MUST be promoted to style.css. Duplicated presentation is drift with a delay: the copies diverge on the first edit that does not touch all of them.
The auth shell was reconciled under this rule — .auth-page, .auth-body .form-label, .auth-body .subtitle, .auth-body .form-text, .auth-body .form-group, .auth-body .footer-links, and .auth-code-input are defined once in the Auth Shell section of style.css. The six HTTP error pages were reconciled the same way: their shared chrome is defined once under body.error-page in the Error Pages section, and each page keeps only the rules specific to it.
The brand mark
The brand is text, not an image. It is the three-row wordmark — CLASSROOM / TOKEN / HUB, each row prefixed by its Material Symbol (local_atm, store, finance_mode) — stacked vertically and rendered from a macro, never transcribed inline.
A raster or vector logo file MUST NOT be used to represent the brand on any public page. Image logos cannot take the role theme, cannot be recolored by a token, and cannot be read by a screen reader without a redundant alt string. The wordmark satisfies all three by construction.
Two macros render it, and a third surface MUST reuse one rather than add a fourth:
| Surface | Macro / class | Layout |
|---|---|---|
| Landing and docs heroes | macros/docs_hero.html → .landing-brand |
Vertical stack, gold rule on the trailing edge |
| Auth shell (sign-in, sign-up, recovery) | macros/auth_brand.html → .auth-brand |
Vertical stack in a full-width band |
The auth shell
Sign-in, sign-up, and recovery pages use one stacked layout: the brand band on top, the interactive panel (.auth-body) beneath. A two-column split that places the brand beside the form is prohibited — it forces the brand to be dropped entirely at narrow widths, which is what the prior @media (max-width: 768px) { .login-right { display: none } } rule did on all fourteen pages.
The auth card is square. .auth-container sets width, margin, background, box-shadow and overflow, and no border-radius; --shadow-lg alone lifts it off the page. Through v2.1 this table assigned --radius-xl to “auth cards and feature panels”, which no rule applied and no page rendered — the fourteen auth pages have shipped square throughout. The square card is the intended form: it is coherent with the flat, border-over-shadow treatment in §V.1.3, and a documented radius that nothing applies is a spec describing a UI that does not exist. --radius-xl remains the value for feature panels.
The band’s fill is var(--primary), so the role theme carried on <body> selects it with no per-page override and no role conditional (§V.2). Wordmark text is var(--text-inverse); the leading icons are var(--secondary) and are decorative (aria-hidden="true"), per §VIII.4.
Inside .auth-body the surface is var(--surface), so no text may use --secondary or --secondary-hover — they are 2.12:1 and 2.69:1 on white. Links, subtitles, field labels, and helper text take var(--accent-text-on-light) (§V.6). The shell defines .auth-body a:not(.btn) as a floor precisely because these pages carry page-local <style> blocks: a bare a { color: var(--secondary) } in one of them loses to the floor on specificity, so the accessible colour survives a page that reaches for the accent again. A hover MUST NOT be expressed as opacity on the link — diluting --accent-text-on-light to 80% lands at 4.21:1, below the §XI threshold. Use text-decoration: underline.
Third-party widgets sized in pixels. .auth-container sets overflow: hidden, and the panel’s content box is ~304px at its widest and narrower on every phone. Any embedded widget with a fixed pixel width MUST be checked against that budget rather than assumed to fit. Cloudflare Turnstile is the live case: its default widget is a fixed 300px and its “flexible” mode still floors at 300px, so the panel clips it on any viewport below ~396px — which would leave the form unsubmittable. macros/turnstile.html is the only sanctioned way to render it; it selects the documented 150px compact size below that threshold. Do not hand-write a .cf-turnstile div.
X. Anti-Patterns
Hardcoded color
<!-- Prohibited -->
<div style="background-color: #1a4d47; color: white;">…</div>
<!-- Required -->
<div class="bg-primary text-white">…</div>Opacity hack
<!-- Prohibited: Bootstrap's opacity is inconsistent across themes -->
<div class="bg-success bg-opacity-25">…</div>
<!-- Required -->
<div class="bg-success-subtle">…</div>Role branching
<!-- Prohibited -->
{% if role == 'student' %}<div class="bg-blue-800">{% else %}<div class="bg-teal-900">{% endif %}
<!-- Required: let the theme layer resolve it -->
<div class="bg-primary">…</div>Inline icon sizing
<!-- Prohibited -->
<span class="material-symbols-outlined" style="font-size: 1.25rem">settings</span>
<!-- Required -->
<span class="material-symbols-outlined icon-md">settings</span>Browser-native dialog
// Prohibited: drawn by the browser, off-system, and not reliably shown by in-app browsers
if (!confirm('Delete this fine?')) return;
const pin = prompt('Enter your PIN:');
// Required: the in-app modal (static/js/app-core.js)
if (!(await window.AppCore.confirm('Delete this fine?', { title: 'Delete fine', confirmLabel: 'Delete', danger: true }))) return;
const pin = await window.AppCore.prompt('Enter your PIN.', { title: 'Start Work', label: 'Your PIN', type: 'password' });<!-- Required for a plain form post -->
<button type="submit" data-confirm="Pay this bill?" data-confirm-title="Pay bill" data-confirm-label="Pay">Pay</button>alert(), confirm() and prompt() are prohibited in first-party pages. On 2026-10-20 the Google app’s iOS in-app browser returned an empty PIN from a prompt() without showing it. A student’s hall-pass request never left the page, and nothing told the student why.
Token whose value restates another token
/* Prohibited: will not follow --primary when it changes */
--sysadmin-primary: #303030;
/* Required */
--sysadmin-primary: var(--primary);XI. Accessibility Bindings
INV-ARC-020 governs. This section records only where the design system carries specific obligations.
- Contrast is a token property. Any token pair intended to be composed as foreground-on-background MUST meet WCAG 2.1 AA in its rendered state, in every role theme.
--secondaryis the highest-risk token: at#D4A857it requires dark foreground, which is why--secondary-textexists and why it differs by role. - Never color alone. A state communicated by a status token MUST also carry text, icon, or shape.
- Focus is not optional. Focus-visible styling is part of the system, not a per-component decision.
- Reduced motion. Every token in the motion scale is subject to the global
prefers-reduced-motion: reduceoverride. - Forced colors.
forced-colors: activesupport MUST be preserved when component styling changes. - Modal dialogs. While a modal is open, everything outside it MUST be
inert, so screen readers cannot browse to it and Tab cannot reach it.aria-modalalone does not do this in Chromium. The dialog MUST have an accessible name, plus a description when it asks a question. Focus moves into the dialog on open: to its field, or to Cancel when the action is destructive. On close, focus MUST return to the control that opened the dialog. A field error MUST be linked througharia-describedbyandaria-invalidonly once the error applies.static/js/app-core.jsmeets these obligations for every Bootstrap modal and forAppCore.confirm/AppCore.prompt.
XII. Conformance
A surface conforms when:
- no design value is expressed literally; every one resolves through a token. Structural values (§IX) are exempt and are the only exemption
- no CSS rule in a page-scoped
<style>block is also matched by a rule instyle.css, whether the page copy agrees with it or is beaten by it - no template carries a hardcoded color, in markup,
<style>, or script - every inline
styleattribute is per-render computed - role identity is carried solely by the body class
- every role theme defines the complete theme-layer token set, with RGB parity
- icon sizing uses
.icon-*utilities INV-ARC-020§VI baseline is preserved
The token layer conforms when:
- no
--bs-*-rgbdisagrees with its paired token - no theme omits a theme-layer token another theme defines
- no token restates another token’s literal value
style.csscontains no raw color outside a token definition
XIII. Separately Published Artifacts
github-pages/ and docs-site/ maintain their own stylesheets and are outside the runtime token layer. They are nonetheless brand surfaces and MUST NOT contradict §VI.4.
The developer documentation site published at /docs/ is built from docs-generator-proof/ with Hugo + Hextra (it replaced the Docusaurus build in docs-site/ on 2026-10-24). It loads github-pages/style.css byte-for-byte as its public stylesheet and docs-site/src/css/tokens.css as its token layer, and its own stylesheet, docs-generator-proof/assets/css/custom.css, is subordinate to this specification on the same terms as status.css: every design value in it MUST resolve through a token defined in those two layers, and it MUST NOT introduce a colour. Hextra’s own custom properties (--hx-*, --hextra-*) are framework-owned and are mapped onto project tokens there, never the reverse. docs-site/ remains a brand surface while it stays in the repository as the fallback build.
github-pages/style.css carried --secondary: #d3af37 and --primary-color: #236960 through v2.1; both were reconciled on 2026-09-19. status_service/ is served from the same stylesheet: status_service/static/cth-public.css is a byte-identical copy of github-pages/style.css, and a divergence between them is therefore a diff, not a silent fork. It held one for roughly a day — a copy frozen immediately before the 2026-09-19 fix, which left .toc a:hover accessible on one host and at 3.16:1 on the other — and that is the failure mode the byte-identity requirement exists to prevent.
The status service also carries the runtime token layer: status_service/static/tokens.css is a byte-identical copy of static/css/tokens.css, loaded after cth-public.css, so the status pages and the operator console draw on the same spacing, type, radius, elevation and colour tokens as the application, and the operator console takes the sysadmin identity through body.sysadmin-shell (§V.2). Its own stylesheet, status_service/static/status.css, is subordinate to this specification: it may add state chrome the other stylesheets have no use for, but every design value in it MUST resolve through a token defined in those two token layers. It MUST NOT introduce a colour. Service states derive from the status colours in §VI and always carry a word as well as a colour, per §XI.2.
XIV. Enforcement
Design-system conformance is mechanically checkable and SHOULD be gated rather than reviewed by eye. scripts/lint_design_tokens.py gates rules 1 and 5–10, and tests/test_design_token_contract.py runs it with one test per rule; run the script directly on the files being edited. The test also carries a mutation proof for every rule — a synthetic near-miss violation it asserts is reported — so the gate cannot go green because the scanner stopped seeing.
Rules 1, 5, 6 and 7 scan templates/. Rules 8, 9 and 10 scan the token layer and stylesheets of every brand surface named in §XIII, not the application alone. The application was the only gated surface through v2.1, and it was also the only one that had not drifted; that is not a coincidence, and it is why the scope is now the surface list rather than templates/. The checkable rules:
- No hex/
rgb()/hsl()literal undertemplates/. - No raw color in
style.cssoutside a token definition block. --bs-*-rgbparity with its paired token.- Theme-layer token set completeness across all three role blocks.
- No inline
styleattribute lacking a Jinja expression. - No literal length, weight, duration, or opacity in a template
<style>block outside the structural categories named in §IX. - No selector appearing in both a template
<style>block andstyle.css. - No colour literal outside a token definition, on any surface. A literal is legitimate only in a declaration that defines a custom property — a property test, not a selector test, since the role themes are token blocks mounted on
body.student-shellandbody.sysadmin-shellrather than on:root. An achromatic value used as an operand inside a colour function (color-mix(… , black),rgba(0,0,0,.08)) is a shading operation rather than a brand colour and is admitted; the same word standing alone as a whole value is not, becausecolor: whiteis--text-inversespelled wrong. - No
var(--token)reference that the surface’s own token layer does not define. Such a declaration is invalid and the property falls back silently, which is how--font-datarendered the status page’s capability ids in Inter, and how the public site’s--secondary-colorleaked intostyle.cssand failed on seven lines. Framework namespaces (--bs-,--ifm-) are owned elsewhere and are exempt per surface. - A stylesheet declared a copy of another is byte-identical to it.
status_service/static/cth-public.cssMUST equalgithub-pages/style.css, andstatus_service/static/tokens.cssMUST equalstatic/css/tokens.css. A copy that is allowed to drift is a fork nobody declared; this one drifted for a day and left the same selector accessible on one host and at 3.16:1 on the other.
Rule 7 catches a failure mode the others miss. A page copy that loses the cascade renders nothing, so it can drift arbitrarily far from the shared rule without any visible symptom — it is edited, reviewed, and merged as if it were live. The fourteen auth templates each carried a .btn-primary block that had not rendered since style.css claimed the selector with !important, and three of them had independently drifted the hover fill to a colour that would have failed contrast had it ever applied.
tests/test_no_native_browser_dialogs.py gates the browser-native dialog anti-pattern (§X) across templates/, static/ (except static/vendor/) and app/, with a mutation proof for each spelling. tests/test_app_dialog_accessibility_browser.py checks §XI.6 in Chromium. It reads the accessibility tree over CDP and runs an axe-core WCAG 2.1 A/AA audit of the open dialog. The same file measures the text contrast of every button variant under keyboard focus and hover, in each role theme. axe audits resting states only, and three outline variants failed AA under :focus-visible while axe passed them.
Per INV-ARC-020 §VIII.4, any change to static/css/ affecting rendered color, spacing, display state, or focus styling is a high-risk change class and carries the accessibility validation obligation in INV-ARC-020 §X.
XV. Amendment
Revisions MUST:
- Increment the version and update the effective date.
- Preserve the role-theme contract (§V.2) and the token-layer single-source rule.
- Preserve or strengthen
INV-ARC-020obligations; a revision MUST NOT weaken them. - Update §VI.4 when a canonical brand value changes, and state what the prior value was.
Last updated: 2026-10-24