diff --git a/docs/theme/THEME-CONTRACT.md b/docs/theme/THEME-CONTRACT.md new file mode 100644 index 0000000000..8b0302b3cc --- /dev/null +++ b/docs/theme/THEME-CONTRACT.md @@ -0,0 +1,1597 @@ +# OnTrack theme contract + +**Ticket:** THM-D02 · **Status:** Draft, awaiting approval · **Applies to:** `doubtfire-web` + +This is the contract every contributor works to before anyone edits styles for Light/Dark/System. +It fixes the theme states, the semantic token names, how the preference is stored, the +accessibility floor, and what the implementation tickets must prove. It does not implement +anything. + +Read sections 4, 6, 7 and 10 before writing any style code. The rest is why. + +## 14 September 2026 correction handover + +This note records the scoped correction candidate in the Unit Hub release branch. The audit below +remains a historical snapshot of its named August revision; this note does not approve the full +theme MVP or change its palette. The fixes consume the existing `--ot-color-*` tokens. + +| Surface | Correction to verify | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cross-project dashboard (`/dashboard`) | Native date/search fields, placeholders, filter controls and quieter dark unit headers | +| Project dashboard (`/projects/:id/dashboard`) | Progress container, grade fields, learning outcomes, engagement/peer summary text, planner links, task search, selected rows and mobile panes/tabs | +| Progress burndown | SVG axis labels, grid lines and legend states; the scoped chart rule reaches the library's child SVG | +| Task Planner and tutorials | Gantt surfaces/text and toolbar controls; tutorial cards and empty states | +| Calendar, About and demo controls | Dialog fields, links, headings, cards and the demo banner | + +The final frontend regression run passed **1,050 tests across 141 files** on Node 22. +Full lint, typecheck, deployment configuration validation and the production build passed. +The build retains its existing stylesheet-size and dependency warnings; budgets were not raised. +The old test requiring a permanently white search field now checks its label, placeholder, +search type and keyboard focus. Planner regressions exercise the library's English locale +and supported empty template. + +Browser checks used fictional local accounts in the installed in-app browser: + +- Dark dashboard date/search controls, progress panels, chart labels, tutorials, About, + calendar tabs/download action and PPI/push previews were inspected after rebuilding. +- Light-mode comparison covered the same shared tokens and core planner, dashboard, + calendar and preview surfaces. Theme switching updated existing page colours. +- Phone layout checks included 320 CSS pixels for About, PPI/push previews and the + dashboard, and a phone-width planner with its English empty state visible. The + tutorials table stays inside a keyboard-scrollable region. Mobile overview/task-list + navigation remained usable. +- Announcement and session blank areas opened their detail views. Keyboard activation + worked; separate Join, calendar and download controls remained independent. + +Record the final component revisions and local evidence in the release handover. These checks +are a scoped correction check, not full theme-MVP acceptance or accessibility certification. +Physical iOS/Android devices and operating-system-driven System-theme changes still need the +receiving team's acceptance. Live tenant and personal-calendar acceptance are separate from +this styling correction; no real meeting invitation or enrolment was changed. + +--- + +## 1. Missing inputs, and what is provisional because of them + +THM-D02 was scheduled after two tickets that have not started. + +| Ticket | Owner | What it was meant to hand over | State | +| ------- | ----------------- | -------------------------------------------------- | ----------- | +| THM-D01 | Owen Brian Costin | Theme audit and dark-mode blueprint | Not started | +| MG-05 | Unassigned | CSS style guide (naming, layering, file ownership) | Not started | + +So the audit in section 2 was done from scratch against the working tree at +`origin/11.0.x`. Every number in it comes from a command that is printed next to it, and every +statement about how OnTrack styles itself today comes from a file that was opened. + +Three things stay provisional until those two tickets land. + +| Provisional | Why | Who settles it | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| The token **naming prefix** `--ot-*` and the `--ot-color-* / --ot-status-* / --ot-chart-*` grouping | MG-05 owns naming. `--ot-` was chosen because it collides with nothing in the tree today (section 2.6). | MG-05 | +| The **file layout** in section 10 (`src/styles/tokens/_light.scss`, `_dark.scss`) | MG-05 owns which layer may declare a colour. | MG-05 | +| The **per-page migration order** for THM-M01 to THM-M04 | THM-D01's audit was meant to rank pages by risk. Section 2.5 lists the hard surfaces but not an order. | THM-D01, and THM-M05 for the FlexLayout sequencing | + +Everything else here — states, storage, tokens, values, accessibility floor, test list — is +proposed as final and is not waiting on either ticket. + +--- + +## 2. Audit of the current stylesheet, done for this ticket + +Commands were run from the repo root on branch `docs/theme-contract`, rebased onto `origin/11.0.x` +at **`4034e7d1a`** (27 Aug 2026, the closure merge of PR #105). Every count below was re-taken on +that commit. Appendix B has the full list with each command beside its result, and the note there +on which nine numbers moved when the closure branch landed. + +### 2.1 Versions + +`package.json`: Angular `^22.0.3`, Angular Material `^22.0.2`, Tailwind via +`@tailwindcss/postcss ^4.3.2`, tests on `vitest ^4.1.9` through the +`@angular/build:unit-test` builder. Node `>=22.22.3`. + +The Thoth Tech handover says Angular 17 and Karma. Both are stale. Do not plan against them. + +### 2.2 How Material is themed today + +`angular.json` loads two global stylesheets, in this order: `src/theme.scss`, then +`src/styles.scss`. + +`src/theme.scss` builds a **Material 2** theme. It defines three palettes by hand +(`$md-formatif` from `#3939ff`, an accent, a warn), calls `mat.m2-define-light-theme(...)`, then +`@include mat.all-component-themes($theme)`. There is one theme and it is light. The file defines +no dark theme at all — line 162 only reads `is-dark` back off the light config — and there is no +`.dark-theme` class anywhere in the tree. The repo's only `$dark-theme` is at +`src/styles/m3-theme.scss:158`, in the file nothing imports. + +`src/styles/m3-theme.scss` **already exists**, is 177 lines, and is generated +(`// This file was generated by running 'ng generate @angular/material:m3-theme'`). It defines +**both** `$light-theme` and `$dark-theme` with `theme-type: light` / `theme-type: dark`, +`use-system-variables: true` and `system-variables-prefix: sys`. + +It is not used. `src/styles.scss` lines 24-25 have it commented out: + +```scss +// This is the new Angular Material 3 theme file which we will migrate to +// @use './styles/m3-theme.scss'; +``` + +``` +$ grep -rIn -- "--sys-" src/ | wc -l +0 +``` + +Nothing consumes it. Turning that comment into code is **not** the plan — see section 3. + +`mat.app-background()` and `mat.elevation-classes()` are each included twice, once in +`theme.scss` and again in `styles.scss`. Harmless duplication today, worth a cleanup ticket, not +this one. + +### 2.3 The colours the app actually paints + +Because the M2 light theme is what runs, the base surfaces come from Material's own light +palettes at `node_modules/@angular/material/core/m2/_palette.scss`: + +| Role | M2 source | Value | +| -------------- | ------------------------------------------------------ | --------------------- | +| page | `$light-theme-background-palette.background` = grey 50 | `#fafafa` | +| card, dialog | `.card` / `.dialog` | `#ffffff` | +| app bar | grey 100 | `#f5f5f5` | +| body text | `rgba(black, 0.87)` | flattens to `#212121` | +| secondary text | `rgba(black, 0.54)` | flattens to `#757575` | +| disabled text | `rgba(black, 0.38)` | flattens to `#9e9e9e` | +| dividers | `rgba(black, 0.12)` | flattens to `#e0e0e0` | + +Those four flattened values land exactly on M2 grey 900 / 600 / 500 / 300. That is not a +coincidence, it is how the palette was built, and it is why the light column in section 7 is a +derivation and not an invention. + +The four **text and divider** rows are reproducible without Material installed: they are the +alpha values above composited over white, and `flatten()` in Appendix A returns `#212121`, +`#757575`, `#9e9e9e` and `#e0e0e0` exactly. The three **surface** rows (`#fafafa`, `#ffffff`, +`#f5f5f5`) are read from the M2 palette, and `node_modules` is not installed in this worktree, so +they are quoted from the package rather than re-checked here. They are the stock M2 grey 50 / +white / grey 100 and nothing in the repo overrides them, but treat the file path as a pointer +rather than a citation until someone re-runs it with dependencies installed. + +### 2.4 Hard-coded colour, measured + +``` +$ grep -rIoE '#[0-9a-fA-F]{3,8}\b' src --include='*.scss' | wc -l +471 +$ grep -rIlE 'rgba?\(|hsla?\(' src --include='*.scss' | wc -l +25 +$ find src -name '*.scss' | wc -l +189 +$ grep -rIoh '!important' src --include='*.scss' | wc -l +63 +``` + +238 of the 471 hex literals are in three files that are palettes by design +(`m3-theme.scss` 107, `theme.scss` 84, `task-status-colors.scss` 47). Of the rest, **226 are +scattered across component SCSS under `src/app`**, 6 sit in other shared partials and 1 is in +`styles.scss`. + +``` +$ grep -rIoE '#[0-9a-fA-F]{3,8}\b' src/app --include='*.scss' | wc -l +226 +$ grep -rIlE '#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(' src/app --include='*.scss' | wc -l +44 +$ grep -rIlE 'color|background|border|fill|stroke' src/app --include='*.scss' | wc -l +57 +$ find src/app -name '*.scss' | wc -l +178 +``` + +So **57 of the 178 component stylesheets under `src/app` declare a colour, a border or a fill**, +against 44 that hold a hex or an `rgba()`. The wider grep is the honest one for sizing a +migration: it also catches `stroke: white`, `border-radius: $border-radius-base` and a +`task-status-color($status)` call, none of which contain a literal but all of which a theme has +to account for. 57 is the number to burn down, 226 is the number of literals inside it. + +The three palette files have not moved (107 / 84 / 47, unchanged), so the whole of the growth is +in component stylesheets: the loose literal count more than doubled from 105 to 226 in a single +closure merge. Two files new to this base carry 94 of the 226 between them, +`ppi-widget.component.scss` with 50 and `demo-controls.component.scss` with 44. That is the rate +this contract exists to stop, and it is the strongest argument for landing the section 10 lint +gate early rather than after the migration — every week it is not in place, the burn-down grows +faster than a migration ticket can shrink it. + +Templates carry colour too, as Tailwind arbitrary values: + +``` +$ grep -rIhoE '\b(text|bg|border)-\[#[0-9a-fA-F]{3,8}\]' src --include='*.html' --include='*.ts' \ + | sort | uniq -c | sort -rn + 5 text-[#c5c5c5] 3 bg-[#da532c] 2 text-[#969696] 2 bg-[#e7e7ff] + 1 text-[#fab143] 1 text-[#da532c] 1 text-[#9696969d] 1 text-[#2c2c2c] + 1 bg-[#fab143] 1 bg-[#43a047] 1 bg-[#333] 1 bg-[#126352] +``` + +Nine files. That is the whole of the "someone will invent a new grey" risk, made concrete. + +### 2.5 Status, urgency and the hard surfaces + +**Task status colour has two sources of truth.** +`src/styles/common/task-status-colors.scss` holds a Sass map of 15 statuses with `base`, `dark`, +`light` and `fore`. `src/app/api/models/task-status.ts` holds `STATUS_COLORS`, a second map of +the same 15 hex values in TypeScript. Each file carries a comment telling you to keep it in step +with the other, and the TS one still points at `task-status-colors.less`, a file that no longer +exists. The two maps **do agree today** — all 15 pairs match, checked value by value. They agree +by hand, not by construction. + +**Due-date urgency is inline in one template.** +`src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.html:264-267`: + +```html +[ngClass]="{ 'text-[#da532c]': task.daysUntilDueDate() <= 0, 'font-normal text-[#fab143]': +task.daysUntilDueDate() > 0 && task.daysUntilDueDate() < 11, 'font-normal text-gray-500': +task.daysUntilDueDate() > 11, }" +``` + +Three urgency bands, no named token, one of them a Tailwind default grey. + +**Surfaces THM-M04 will fight**, all confirmed present in `package.json` and in use: + +| Surface | Library | Why it is hard | +| --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Code editors | `monaco-editor`, `ngx-monaco-editor-v2-alternative`, `@ngstack/code-editor` | Monaco takes a theme **name**, not CSS. Seven call sites pin `theme: 'vs'` or `'vs-dark'` literally. | +| Charts | `@swimlane/ngx-charts`, `d3` | Colour arrives as a JS `domain` array, not CSS. Two hard-coded domains found. | +| PDF / portfolio | `ng2-pdf-viewer` | Renders into a canvas. CSS cannot reach it; a filter inversion is the only lever. | +| Calendar | `angular-calendar` (CSS imported raw in `styles.scss`) | Third-party CSS with its own light palette. | +| Gantt | `@worktile/gantt` (SCSS loaded in `angular.json`) | Same. | +| Terminal output | `ansi-to-html` | Emits inline `style="color:#..."` from ANSI codes. | +| Emoji picker | `@ctrl/ngx-emoji-mart` (`picker.css`) | Ships a `darkMode` input; `task-comment-composer.component.html:5` currently hard-codes `[darkMode]="false"`. | + +The two chart palettes, verbatim: + +```ts +// visualisations/progress-burndown-chart/progress-burndown-chart.component.ts:70-76 +private readonly seriesPalette: string[] = [ + '#AAAAAA', '#777777', '#0079d8', '#E01B5D', '#7C3AED', +]; +// ...fed to the scheme at :81 as domain: [...this.seriesPalette] +// and per-series at :360 via seriesColor(index) + +// common/project-progress/project-progress-gauge.component.ts:41 +domain: ['#5AA454', '#E44D25', '#CFC0BB', '#7aa3e5', '#a8385d', '#aae3f5'], +``` + +The burndown palette moved during the closure merge: it was an inline `domain` array and is now a +named constant, and its fifth slot changed from `'transparent'` to a real fifth colour `#7C3AED`. +That is one more series colour for THM-M04 to map onto `--ot-chart-*`, and section 8.2 allocates +six slots, so it fits. The refactor is also the better shape to migrate — one constant to +re-point instead of two call sites. + +### 2.6 What does not exist yet + +``` +$ grep -rIn "prefers-color-scheme" src/ | wc -l +0 +$ grep -rIn "color-scheme" src/ | wc -l +0 +$ grep -rIn "@media print" src/ | wc -l +0 +$ grep -rIn "prefers-reduced-motion" src/ | wc -l +2 +``` + +The two `prefers-reduced-motion` blocks are new and both are component-local +(`demo-controls.component.scss:497`, `ppi-widget.component.scss:503`). There is still no +app-wide reduced-motion rule, so section 12 sets one rather than assuming it is covered. + +No dark preference is stored anywhere. `src/index.html` sets a fixed +`` and `src/manifest.webmanifest` sets +`"theme_color": "#3939ff"`, `"background_color": "#ffffff"`. `` is +the only body class. + +CSS custom properties are barely used, which is why the token namespace is free: + +``` +$ grep -rIhoE '^\s*--[a-z0-9-]+\s*:' src --include='*.scss' | tr -d ' ' | sort | uniq -c + 3 --background-gray: 2 --mat-chip-disabled-label-text-color: + 1 --status-chip-bg: 1 --mat-tab-container-height: + 1 --mat-progress-bar-track-height: 1 --mat-progress-bar-active-indicator-height: + 1 --mat-badge-text-color: 1 --mat-badge-container-overlap-offset: + 1 --mat-badge-container-offset: +``` + +Nine names, twelve declarations, and six of the nine are Material's own `--mat-*` overrides +rather than anything of ours. The `--ot-` prefix still collides with nothing. + +Focus handling is a live risk, and the headline counts understate it. There are 11 +`outline: none` / `outline: 0` declarations and 11 `:focus-visible` rules, which reads as level +where it used to read 11-against-7. It is not level. The two sets barely overlap: + +``` +$ comm -12 <(grep -rIlE "outline:\s*(none|0)" src --include='*.scss' | sort) \ + <(grep -rIl "focus-visible" src --include='*.scss' | sort) +src/app/common/file-uploader/file-uploader.component.scss +``` + +Ten files suppress an outline and seven declare a `:focus-visible` rule, but only **one file does +both** — `file-uploader.component.scss`. The four `:focus-visible` rules added by the closure +merge landed in four files that suppress no outline at all, three of which +(`notifications-page`, `demo-controls`, `ppi-widget`) did not exist on the previous base. So the +gap did not close: **nine of the ten outline-suppressing files still have no replacement ring**, +exactly as before. This is why section 12 makes auditing all 11 suppressions a named item in +THM-F01 rather than treating the ratio as evidence of anything. + +### 2.7 The accessibility problem that already ships + +Status chips pair a `base` fill with a `fore` foreground of `#fff` or `#444`. Measured (method +and script in Appendix A): + +| Status | Fill | Foreground | Ratio | AA 4.5:1 | +| ------------------------------------- | --------- | ---------- | ----- | -------- | +| working-on-it | `#eb8f06` | `#ffffff` | 2.48 | **fail** | +| complete | `#5bb75b` | `#ffffff` | 2.51 | **fail** | +| discuss | `#31b0d5` | `#ffffff` | 2.53 | **fail** | +| attention-required | `#f1814d` | `#ffffff` | 2.63 | **fail** | +| need-help | `#a48fce` | `#ffffff` | 2.84 | **fail** | +| feedback-exceeded | `#d46b54` | `#ffffff` | 3.48 | **fail** | +| demonstrate | `#428bca` | `#ffffff` | 3.63 | **fail** | +| ready-for-feedback | `#0079d8` | `#ffffff` | 4.44 | **fail** | +| fail, time-exceeded | `#d93713` | `#ffffff` | 4.66 | pass | +| not-started | `#cccccc` | `#444444` | 6.06 | pass | +| fix-and-resubmit, assess-in-portfolio | `#f2d85c` | `#444444` | 6.84 | pass | +| rediscuss | `#126352` | `#ffffff` | 7.16 | pass | +| redo | `#804000` | `#ffffff` | 7.92 | pass | + +Eight of thirteen distinct pairs fail AA today, in light mode, before dark mode exists. The +secondary text colour has the same problem: `#757575` on a `#ffffff` card is 4.61 and passes, but +on the `#fafafa` page it is **4.41** and fails. + +This is why section 7 does not simply copy the current values forward. + +--- + +## 3. Material theming approach + +**Decision: a staged bridge. Keep the M2 theme rendering the components, add an independent +semantic token layer on top now, and move Material onto tokens per component group later.** + +### The three options + +| Option | What it means | Verdict | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A. Stay on M2, add a second `m2-define-dark-theme` under a class | One extra `@include mat.all-component-colors($dark)` in a `.dark` block | Rejected. Roughly doubles emitted CSS, gives no token vocabulary for the 233 loose hexes outside the three palette files, and locks us further into an API Angular is winding down. | +| B. Uncomment `m3-theme.scss` and switch wholesale | Swap M2 for the generated M3 light and dark themes | Rejected. See below. | +| **C. Staged bridge** | Ship `--ot-*` tokens and the state machine first; migrate Material component groups after | **Chosen.** | + +### Why B is rejected + +`m3-theme.scss` was generated from `#3939FF` and never wired up. Switching to it in one commit +would, in a single PR: change every Material component's density, shape and typography scale at +once; re-map the `warn` palette (M2 `#ef4444`) onto the M3 `error` ramp (`#ba1a1a`); and leave +the 226 component-level hex literals and the 15 status colours untouched and now clashing with a +new set of greys. The failure mode is exactly the one this ticket exists to prevent: several +people fixing different dark greys in parallel. + +It is also not a small change. `m3-theme.scss:138` and `:158` both call `mat.define-theme`, which +is the older M3 entry point, and the file was generated against it (`m3-theme.scss:1`). Angular +Material has since added `mat.theme()`, which takes a `theme-type` of `color-scheme` and emits +both themes from one include. If we are going to move Material, that is the target, not the +generated file as written — and either way it is a separately sized ticket, not a comment +uncomment. + +> **Not verified here.** `node_modules` is not installed in this worktree, so the `mat.theme()` +> behaviour above is stated from the Material 22 public API and **has not been checked against +> the installed package**. An earlier draft pinned it to +> `node_modules/@angular/material/core/tokens/_system.scss:57-82` and `:253`; those line numbers +> are removed because nothing in this repo can confirm them and a citation nobody can check is +> worth less than none. The rejection of option B does not rest on this paragraph — it rests on +> the blast radius in the paragraph above it, which is verifiable in-tree. Whoever picks up the +> Material migration ticket should confirm the API against the installed version before quoting +> it. + +### What C means concretely + +1. **Now (THM-F01).** `src/theme.scss` is untouched. A new token layer declares `--ot-*` on the + root element for both themes. Nothing Material renders changes. +2. **Now.** New and migrated code reads `--ot-*` only. The 226 loose literals get replaced page + by page (THM-M01 to M04) with no visual change in light mode. +3. **Later, per component group.** Material components are pulled onto the tokens with + `mat.theme-overrides()` or `--mat-*` system variables, one group per PR (buttons, then form + fields, then tables, and so on). Each PR is independently reviewable and revertible. +4. **Later still.** When every group is covered, `theme.scss` shrinks to typography and the M2 + colour include is deleted. + +### Compatibility + +- `--ot-*` custom properties inherit and cascade. They cannot break an M2 rule that does not + reference them, so step 1 is inert by construction. +- `light-dark()` is **not** used in the token layer. Values are written literally under the DOM + marker so the resolved theme is legible in DevTools and does not depend on the browser's + `color-scheme` computation. The `color-scheme` CSS property is still set (section 11) so + scrollbars and form controls follow. +- Tailwind's `important: true` in `tailwind.config.js` means every utility already outranks + Material component CSS. Reading a token through a utility keeps that behaviour, it does not + change it. + +### Rollback + +| Stage failing | Rollback | Blast radius | +| -------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Token layer | Remove the `@use` of the token file. Every `var(--ot-x, )` falls back to its literal. | None, if the fallback rule in section 10 is obeyed. | +| Toggle / persistence | Feature-flag the toggle off. The DOM marker is then never set, and the `:root` block is the light theme. | None. Light mode is the unmarked default by design. | +| One component group | Revert that one PR. | That group only. | +| A migrated page | Revert that one PR. | That page only. | + +**Every `var()` in the token layer's consumers must carry a literal fallback matching today's +light value.** That single rule is what makes rollback free, and it is a review gate. + +--- + +## 4. Theme states + +Two vocabularies. They are not the same set and they are never stored in the same place. + +| | Values | Where it lives | Who writes it | +| --------------------- | ------------------------- | -------------------------------- | ------------------------ | +| **Stored preference** | `light`, `dark`, `system` | `localStorage`, key in section 6 | The user, via the toggle | +| **Resolved theme** | `light`, `dark` | In memory, and on the DOM marker | `ThemeService` only | + +The stored preference is the same three values wherever it is kept. THM-B01 adds a second store +for it on the user account (section 6.2); it does not add a fourth value, a fourth state or a +new resolution rule, so everything below survives phase two unchanged. + +`system` is never a resolved value. It is an instruction meaning _follow +`prefers-color-scheme`_. Anything that needs to know what is on screen reads the resolved +theme. Anything that renders the toggle reads the stored preference, so the toggle can show +three options and highlight `system`. + +Resolution is one line: + +``` +resolved = stored === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : stored +``` + +### State diagram + +```mermaid +stateDiagram-v2 + [*] --> Read: app boot (pre-hydration script) + + Read --> System: key missing + Read --> System: value not in the allowlist + Read --> Light: value = "light" + Read --> Dark: value = "dark" + Read --> System: value = "system" + + state "stored = system" as System { + [*] --> ResolveOS + ResolveOS --> SysLight: prefers-color-scheme is not dark + ResolveOS --> SysDark: prefers-color-scheme is dark + SysLight --> SysDark: OS switches to dark (live) + SysDark --> SysLight: OS switches to light (live) + } + + state "stored = light" as Light + state "stored = dark" as Dark + + Light --> Dark: user picks Dark + Light --> System: user picks System + Dark --> Light: user picks Light + Dark --> System: user picks System + System --> Light: user picks Light + System --> Dark: user picks Dark + + note right of System + resolved = light or dark + OS changes apply live, no reload + end note + note right of Dark + resolved = dark, pinned + OS changes are ignored + end note +``` + +Three rules fall out of the diagram and are testable: + +1. An unreadable, missing or invalid stored value resolves to `system`. Never to a hard `dark`. +2. While stored is `light` or `dark`, OS changes are ignored. The user's explicit choice wins. +3. Changing the OS preference while stored is `system` repaints without a reload. + +--- + +## 5. DOM marker + +**One attribute, on ``, carrying the resolved theme only:** + +```html + +``` + +- Name: `data-ot-theme`. Values: exactly `light` or `dark`. Nothing else is ever written. +- It goes on `document.documentElement`, not ``. `` is + already load-bearing and the pre-hydration script (section 11) runs in ``, before + `` exists. +- **In light mode the attribute is present and set to `light`.** It is not omitted. A selector + should never have to mean "absence of dark". +- A data attribute, not a class, because `` classes are a shared namespace that Angular, + Material and Tailwind all write into, and a stray `.dark` is easy to add by accident. An + attribute with a fixed two-value domain is not. +- Selector form for the token layer, and the only two blocks that may declare a colour value: + +```scss +:root, +:root[data-ot-theme='light'] { + /* light values */ +} + +:root[data-ot-theme='dark'] { + /* dark values */ +} +``` + +The bare `:root` first means the app is correct in light mode even if the script never runs. + +--- + +## 6. Storage + +| Field | Value | +| ------------------- | ---------------------------------------------------------------------------- | +| Key | `ontrack.theme.preference` | +| Allowed values | `light`, `dark`, `system` — exactly these three lowercase ASCII strings | +| Default when absent | `system` | +| Format | A bare string. **Not** JSON, not an object, not a serialised class | +| Scope | `localStorage`, per browser profile and origin. Section 6.2 adds the account | +| Sync provenance | `ontrack.theme.preference.accountId`, never read on the first-paint path | + +The key follows the convention already in the tree, which now has two independent uses of it: + +``` +src/app/units/task-viewer/directives/unit-task-list/unit-task-list.component.ts:586 + `ontrack.unitTaskList.${unitId}.viewPreferences` +src/app/projects/states/plan/task-planner/task-planner.component.ts:82 + `ontrack.taskPlanner.${projectId}.showTasksAboveTargetGrade` +``` + +Namespace, dot separators, camelCase leaf. `ontrack.theme.preference` is the same shape with no +id segment, because the preference is per browser profile rather than per unit or per project. + +### 6.1 Validation, and the rule that matters + +```ts +export type ThemePreference = 'light' | 'dark' | 'system'; +const THEME_PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'] as const; +const THEME_STORAGE_KEY = 'ontrack.theme.preference'; + +function isThemePreference(v: unknown): v is ThemePreference { + return typeof v === 'string' && (THEME_PREFERENCES as readonly string[]).includes(v); +} + +function readThemePreference(): ThemePreference { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + return isThemePreference(raw) ? raw : 'system'; + } catch { + return 'system'; // private mode, blocked storage, quota + } +} +``` + +**The stored string is never evaluated as CSS or as code.** It is compared against a fixed +allowlist and then used only to pick one of two literal attribute values. It is never +interpolated into a stylesheet, a `style` attribute, a class name, a selector, an +`innerHTML`, a `Function`, or an `eval`. There is no path from `localStorage` to the CSSOM. + +Any value not in the allowlist — a typo, a stale value from an older build, a string another +tab wrote, a value a user typed into DevTools — falls back to `system` **and is not rewritten**. +Silently overwriting it would hide the fact that something is writing junk to that key. + +The same allowlist governs the write side. `setPreference` takes `ThemePreference`, so an +invalid value cannot be stored through the service at all. + +### 6.2 Cross-device persistence — THM-B01, phase two + +`localStorage` is per browser profile. Sign in on a laptop and on a lab machine and you set the +preference twice. **THM-B01 closes that**, and this section is the contract it builds to, so the +card can be specified now and landed after THM-F01 without reopening anything here. + +The client-only store is not "local forever". It is phase one of two. + +| Phase | Store | Ticket | +| ---------------- | ---------------------------------------- | ------- | +| One, ships first | `localStorage` only | THM-F01 | +| Two, adds to it | `localStorage` **plus** the user account | THM-B01 | + +Phase two **adds** a store. It does not replace one, and it changes nothing in section 4, +section 5 or the startup script. Five rules hold the two stores together. + +**1. Local is always the boot store.** The script in section 11 runs before the app has a +session, so `localStorage` is the only thing it can read. The account value is not reachable at +first paint and must never be waited on, or the no-flash guarantee is gone. Phase two therefore +does not touch startup at all. + +**2. The account stores the preference, never the resolved theme.** A user who picked `system` +has `system` on their account. It never becomes `dark` because their operating system was dark +when they last signed in. Same three values, same allowlist as 6.1, validated again server-side. +This is the rule that keeps section 15's objection answered: the account never learns the user's +OS appearance. + +**3. Conflict is settled by last write, not by store.** Neither side outranks the other. + +- The device writes `ontrack.theme.preference.updatedAt`, an ISO 8601 string, beside the + preference. A **second** key on purpose, so the key section 11 reads stays a bare allowlisted + enum and the boot script never parses a date. +- The API carries the equivalent beside its field. +- The device records the numeric account id beside the sync timestamp. This provenance marker is + not part of first paint. A missing marker is the legacy phase-one state and may migrate once; + a marker for another account makes the retained theme presentation-only until the incoming + account is reconciled or that person makes a real choice. +- Newer wins. On a tie the account value wins, so two devices cannot ping-pong. +- The winner is written to **both** sides in the same pass, so one round trip converges them. + +**Presence beats recency, and timestamps are only consulted when both sides hold a value.** This +is the rule that decides the migration, so it is written as a table rather than as prose. Read +"has a preference" as "holds one of the three allowlisted strings"; anything else is treated as +absent, per 6.1. + +| Local preference | Account preference | Outcome | +| ---------------- | ------------------ | --------------------------------------------------------------------------------------- | +| absent | absent | Nothing is stored on either side. Follow `system`. Write nothing anywhere. | +| absent | present | Adopt the account value and write it locally. Rule 4. | +| **present** | **absent** | **Keep the local value, upload it, and stamp `updatedAt` at the moment of the upload.** | +| present | present | Compare timestamps. Newer wins, tie goes to the account. | + +Only the fourth row consults a timestamp. So "a local timestamp that is missing, unparseable or +in the future counts as older, and the account value is taken" applies **inside that row only**, +where the account demonstrably holds a real choice. It must never be read as licence to discard a +local preference against an empty account field. + +**Why the third row matters more than it looks.** It is not an edge case, it is day one of +THM-B01 for the entire existing user base. Phase one writes only +`ontrack.theme.preference`; the `updatedAt` key does not exist until phase two introduces it. So +on the morning THM-B01 ships, every user who ever touched the toggle has a valid local +preference, **no** local timestamp, and an empty account field. Under a naive reading of the +timestamp clause — missing timestamp counts as older, so take the account — every one of them +would silently have their theme reset to `system` by a backend migration they did not ask for. +Presence beating recency is what prevents that, and it is why the ordering is fixed here rather +than left to THM-B01. + +Two consequences that follow from the third row and are intended: + +- **The upload stamps `now`, and does not backdate.** The device cannot know when the preference + was originally chosen, and inventing a plausible earlier timestamp would be fabricating the + input to a conflict resolution. `now` is the honest value. +- **If the same user migrates on two devices, the later sign-in wins.** Device A signs in, finds + an empty account, and uploads `dark` stamped `T1`. Device B signs in at `T2` holding `light` + with no timestamp; the account now has a value, so row four applies, B's missing timestamp + counts as older, and B adopts `dark`. Deterministic, converges in one pass, no ping-pong. It + does mean one device's pre-migration choice is dropped, which is the price of having no + timestamp to compare, and THM-Q01 should confirm it looks like a single repaint rather than a + fight. + +**4. A device that has never set a preference adopts the account value.** Nothing valid in +`localStorage` means nothing to compare, so the account value is adopted, written locally, and +the next boot on that device is flash-free. Until the response lands, that first session follows +`system`. One repaint, on the first session on a new device, is the accepted cost of rule 1. It +is stated behaviour, not a defect for THM-Q01 to raise. Where the account is _also_ empty, row +one of the table applies and nothing is written at all — a brand-new user is not given a stored +preference they never chose. + +**5. The sync never blocks and never fails loudly.** A 4xx, a 5xx, an offline device, or a +request still in flight, all leave the local value applied and in charge. The retry is the next +boot. A theme preference is not worth a spinner, a toast or an error state. + +Two further constraints, both review gates. + +- **Sign-out does not clear the key.** It is presentation state, not session state. The next + person on a shared machine sees the previous theme until their own preference arrives, then it + repaints. The ownership marker prevents that retained choice or timestamp from being uploaded + to the next person's account; if their account is empty, it remains presentation-only until + they make a real choice. Clearing the theme would reintroduce a flash for the common single-user + case to fix the rare shared-machine one. +- **Writes are debounced and only fire on a real change.** Cycling the toggle three times sends + one request. + +What THM-B01 owns and this document does not decide: the field name, whether it hangs off the +user record or a separate preferences table, and the endpoint shape. Those are `doubtfire-api` +decisions made with that repo's reviewers. The three values, the allowlist, last-write-wins, and +"preference not resolved theme" are fixed here and are not THM-B01's to change. + +--- + +## 7. Semantic tokens + +Every one of these is a **role**, not a place. There is no `--ot-dashboard-bg`. If a page needs +a colour that is not here, that is a conversation in the theme thread, not a new hex in a +component stylesheet. + +**Derivation.** The light column is the M2 light theme the app renders today (section 2.3), +flattened where Material used alpha, and darkened in 0.5% HSL lightness steps only where the +current value misses 4.5:1 against the **page** background `#fafafa`, which is the worst case. +The dark column is taken from the neutral and primary ramps in the already-checked-in +`src/styles/m3-theme.scss`, so the dark greys are not invented either, then lifted the same way +where needed. Ratios were computed, not estimated. Appendix A has the script. + +| Token | Role | Light | Dark | Worst-case ratio | +| ---------------------------- | --------------------------------------------------------- | --------- | --------- | --------------------- | +| `--ot-color-page` | Page background behind everything | `#fafafa` | `#131316` | — | +| `--ot-color-surface` | Cards, dialogs, table bodies, panels | `#ffffff` | `#201f23` | — | +| `--ot-color-surface-raised` | Menus, popovers, sticky headers, anything above a surface | `#ffffff` | `#2a292d` | — | +| `--ot-color-text` | Body and heading text | `#212121` | `#e5e1e6` | 11.18 on dark raised | +| `--ot-color-text-muted` | Secondary text, captions, hints | `#616161` | `#adaaaf` | 5.93 on light page | +| `--ot-color-border` | Control boundaries: inputs, outlined buttons, chips | `#8a8a8a` | `#78767a` | 3.21 on dark raised | +| `--ot-color-divider` | Decorative rules between rows and sections | `#e0e0e0` | `#353438` | n/a, decorative | +| `--ot-color-focus` | Focus ring | `#3939ff` | `#c0c1ff` | 6.28 light, 8.47 dark | +| `--ot-color-link` | Inline links | `#3939ff` | `#c0c1ff` | 6.28 on light page | +| `--ot-color-primary` | Primary actions, active nav, brand accents | `#3939ff` | `#c0c1ff` | 6.28 on light page | +| `--ot-color-on-primary` | Text and icons on a primary fill | `#ffffff` | `#131316` | 6.56 / 10.87 | +| `--ot-color-success` | Success text and icons | `#398239` | `#5bb75b` | 4.55 on light page | +| `--ot-color-warning` | Warning text and icons | `#a56504` | `#eb8f06` | 4.51 on light page | +| `--ot-color-on-warning` | Text and icons on a filled warning header | `#ffffff` | `#161b22` | 4.71 / 8.89 on fill | +| `--ot-color-error` | Error text, invalid fields, destructive actions | `#d73613` | `#ef6445` | 4.53 on dark raised | +| `--ot-color-on-error` | Text and icons on a filled error header | `#ffffff` | `#161b22` | 4.74 / 6.86 on fill | +| `--ot-color-info` | Informational text and icons | `#0075d0` | `#0792ff` | 4.52 on light page | +| `--ot-color-inverse-surface` | Snackbars, tooltips, anything inverted | `#212121` | `#e5e1e6` | — | +| `--ot-color-inverse-text` | Text on an inverse surface | `#fafafa` | `#201f23` | 15.43 / 12.67 | + +Three notes that are part of the contract, not commentary. + +- **`--ot-color-surface` and `--ot-color-surface-raised` are the same value in light** (`#ffffff`). + In light mode a raised surface is separated by `--ot-elevation-1`, not by hue. In dark mode it + is separated by hue, because shadows do not read on a dark ground. **Never rely on the + raised-versus-surface difference alone to communicate anything.** +- **`--ot-color-border` and `--ot-color-divider` are different tokens on purpose.** WCAG 1.4.11 + requires 3:1 for boundaries that carry meaning, such as the edge of a text input. It does not + apply to a decorative rule between two table rows. `--ot-color-border` clears 3:1 in both + themes. `--ot-color-divider` does not, and must never be used as a control boundary. +- **`--ot-color-text-muted` in light is `#616161`, not the `#757575` the app uses today**, because + `#757575` measures 4.41 against `--ot-color-page`. This is a real fix, not a restyle. + +--- + +## 8. Domain tokens + +### 8.1 Task status + +Fifteen statuses. Every status fill carries white icons and white text, so `-on` is `#ffffff` +for all fifteen in both themes. The method: keep the fill if white already clears 4.5:1; +otherwise darken it in 0.5% HSL lightness steps, hue and saturation held, until white reaches +4.5:1. The dark theme's fills go through the same method. Ratios below are white on the fill. + +| Status | Shipped fill | `--ot-status-*` light | Ratio | `--ot-status-*` dark | Ratio | +| --------------------- | ------------ | --------------------- | ----- | -------------------- | ----- | +| `ready-for-feedback` | `#0079d8` | `#0078d5` | 4.52 | `#0071f1` | 4.53 | +| `not-started` | `#cccccc` | `#757575` | 4.61 | `#6f7782` | 4.53 | +| `working-on-it` | `#eb8f06` | `#a86604` | 4.60 | `#a4690c` | 4.56 | +| `need-help` | `#a48fce` | `#8366bc` | 4.57 | `#9447ff` | 4.56 | +| `fix-and-resubmit` | `#f2d85c` | `#8b750b` | 4.52 | `#877613` | 4.53 | +| `feedback-exceeded` | `#d46b54` | `#ca4e33` | 4.52 | `#cb4e00` | 4.54 | +| `redo` | `#804000` | `#804000` | 7.92 | `#bb5b1f` | 4.54 | +| `discuss` | `#31b0d5` | `#20809c` | 4.54 | `#1c8189` | 4.61 | +| `rediscuss` | `#126352` | `#126352` | 7.16 | `#1b8376` | 4.61 | +| `demonstrate` | `#428bca` | `#337ab7` | 4.56 | `#0074e6` | 4.53 | +| `complete` | `#5bb75b` | `#3b863b` | 4.51 | `#2e863a` | 4.58 | +| `fail` | `#d93713` | `#d93713` | 4.66 | `#eb1309` | 4.54 | +| `time-exceeded` | `#d93713` | `#d93713` | 4.66 | `#eb1309` | 4.54 | +| `assess-in-portfolio` | `#f2d85c` | `#8b750b` | 4.52 | `#877613` | 4.53 | +| `attention-required` | `#f1814d` | `#cd4c10` | 4.53 | `#be580f` | 4.57 | + +Token names are `--ot-status-` and `--ot-status--on`, where `` is the existing +kebab-case class from `TaskStatus.statusClass()`. + +A status drawn as a mark on the page, not as a fill under white content, uses +`--ot-status--graphic`: chart slices and legends, peer-progress bars, list accents and the +footer glyphs. Marks need 3:1 against the surface. In light the fills already clear that, so +`-graphic` equals the fill. In dark the darkened fills do not, so `-graphic` keeps the vivid +colours the dark theme used before, all of which clear 3:1 on `--ot-color-surface`. + +In light, 4 fills keep their value: `redo`, `rediscuss`, `fail`, `time-exceeded`. The rest +darken. The biggest shift is the yellow pair, `fix-and-resubmit` and `assess-in-portfolio`, +which becomes an olive gold. A white glyph cannot clear 3:1 on any yellow light enough to +still read as yellow, so the hue survives and the lightness does not. + +Thirteen of fifteen dark fills are distinct. The two collisions are `fail`/`time-exceeded` and +`fix-and-resubmit`/`assess-in-portfolio`, which already share a base colour today. No **new** +collision is introduced. + +**The two-maps problem must be closed during the shared-component migration, not carried +forward.** No card on the board names it in its own words, and both files are shared rather than +per-page — `src/styles/common/task-status-colors.scss` and `src/app/api/models/task-status.ts` — +so it belongs to **THM-M01, shell and shared components**, and THM-JL01 should confirm that when +the card is written up. After migration `STATUS_COLORS` in `task-status.ts` is deleted and any +TypeScript that needs a status colour reads `var(--ot-status-)`. Where a JS value is +unavoidable (charts), it is read once via +`getComputedStyle(document.documentElement).getPropertyValue(...)` and re-read on theme change. +One source of truth, in CSS. + +### 8.2 Everything else + +| Group | Token | Light | Dark | Note | +| --------- | ----------------------------- | ---------------------------- | ---------------------------- | ---------------------------------------------------------------- | +| Urgency | `--ot-urgency-overdue` | `#c94823` | `#e06f4f` | from `#da532c`; 4.54 light page, 4.51 dark raised | +| | `--ot-urgency-soon` | `#a56504` | `#fab143` | from `#fab143`; 4.51 light page, 7.87 dark raised | +| | `--ot-urgency-later` | `#616161` | `#adaaaf` | alias of `--ot-color-text-muted`; replaces `text-gray-500` | +| Charts | `--ot-chart-1` | `#3939ff` | `#6c6cff` | series slots, ≥3:1 vs their own page (1.4.11); worst 3.02 / 4.52 | +| | `--ot-chart-2` | `#0079d8` | `#007fe2` | | +| | `--ot-chart-3` | `#58a152` | `#5aa454` | | +| | `--ot-chart-4` | `#e44d25` | `#e44d25` | | +| | `--ot-chart-5` | `#a8385d` | `#c7587d` | | +| | `--ot-chart-6` | `#d07e05` | `#eb8f06` | | +| | `--ot-chart-grid` | `#e0e0e0` | `#353438` | gridlines, decorative | +| | `--ot-chart-axis` | `#616161` | `#adaaaf` | axis labels are text, 4.5:1 | +| Meters | `--ot-meter-track` | `#e0e0e0` | `#4a5361` | empty part of a progress bar; 1.43 on dark raised, decorative | +| Comments | `--ot-comment-bubble` | `#e6e9ee` | `#353c47` | other people's chat bubbles; 1.18 on the surface, text 13.2/8.6 | +| Units | `--ot-unit-1` … `-6` | see below | see below | cross-unit dashboard header bands; white text ≥4.5:1 | +| | `--ot-unit-previous` | `#475569` | `#3c4757` | band for units that have finished | +| | `--ot-unit-on` | `#ffffff` | `#ffffff` | text and icons on every unit band | +| Code | `--ot-code-surface` | `#f5f5f5` | `#0e0e11` | `
`, ANSI output, diff panes                                 |
+|           | `--ot-code-text`              | `#212121`                    | `#e5e1e6`                    | 14.77 / 14.91 on their own surface                               |
+|           | Monaco theme name             | `vs`                         | `vs-dark`                    | not a CSS var; set in TS from the resolved theme                 |
+| Overlays  | `--ot-scrim`                  | `rgba(0, 0, 0, 0.32)`        | `rgba(0, 0, 0, 0.60)`        | dialog and drawer backdrop                                       |
+|           | `--ot-elevation-1`            | `0 1px 3px rgba(0,0,0,0.12)` | `0 1px 3px rgba(0,0,0,0.50)` | shadow, not colour                                               |
+| Disabled  | `--ot-color-disabled-text`    | `#9e9e9e`                    | `#6f6d72`                    | 1.4.3 exempts inactive controls; still needs a non-colour cue    |
+|           | `--ot-color-disabled-surface` | `#eeeeee`                    | `#2a292d`                    |                                                                  |
+| Selection | `--ot-color-selected`         | `#e7e7ff`                    | `#2e2e5c`                    | selected row or chip fill                                        |
+|           | `--ot-color-selected-text`    | `#212121`                    | `#e5e1e6`                    | 13.24 / 9.78                                                     |
+|           | `--ot-color-hover`            | `rgba(0, 0, 0, 0.04)`        | `rgba(255, 255, 255, 0.06)`  | matches the M2 `hover` slot already in use                       |
+
+The unit accents give each column on the cross-unit dashboard its own colour, handed out in
+display order. They are identity, not data, so they stay apart from the status and chart
+palettes.
+
+| Token         | Light     | White on it | Dark      | White on it |
+| ------------- | --------- | ----------- | --------- | ----------- |
+| `--ot-unit-1` | `#3939ff` | 6.56        | `#3233c0` | 8.86        |
+| `--ot-unit-2` | `#0f766e` | 5.47        | `#145e5a` | 7.56        |
+| `--ot-unit-3` | `#7c3aed` | 5.70        | `#6134b3` | 7.92        |
+| `--ot-unit-4` | `#be123c` | 6.29        | `#8f1838` | 8.91        |
+| `--ot-unit-5` | `#0369a1` | 5.93        | `#0c557e` | 8.03        |
+| `--ot-unit-6` | `#b45309` | 5.02        | `#884614` | 7.16        |
+
+The dark values are each light accent mixed 70% into the dark page (`#21262d`).
+
+`--ot-color-selected` light is `#e7e7ff`, which is `formatif-blue-lighter` from
+`tailwind.config.js` and `$md-formatif.50` from `theme.scss`. It is already the app's selection
+tint, it just had no name.
+
+---
+
+## 9. Fixed brand vs theme-aware
+
+| Fixed in both themes                                                                       | Why                                                                                                                          |
+| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
+| `#3939ff` as the **brand mark** — the logo, the favicon, `icon.svg`, the PWA `theme_color` | Brand identity. A logo that changes colour is a different logo.                                                              |
+| The status **identity**, meaning which hue means `complete`                                | Green means complete in both themes. Teaching staff read these chips daily and re-learning them in dark mode is a real cost. |
+| `#da532c`, `$doubtfire-color` in `variables.scss`                                          | Legacy brand colour. Keep or retire it, do not theme it.                                                                     |
+
+| Theme-aware                                                 | Why                                                                                                                                                                                                                               |
+| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--ot-color-primary`, `--ot-color-link`, `--ot-color-focus` | `#3939ff` is 6.28:1 on the light page, 2.83:1 on the dark page and 2.20:1 on the worst-case dark raised surface. It misses 4.5:1 for text and 3:1 for a focus ring, so the brand blue cannot be the dark-mode interactive colour. |
+| Every status **fill and foreground**                        | The hue stays recognisable; the lightness has to move or the chip is unreadable. Section 8.1.                                                                                                                                     |
+| All surfaces, text, borders, dividers, shadows, scrims      | Definitionally.                                                                                                                                                                                                                   |
+| Chart series                                                | A series colour is a graphical object under WCAG 1.4.11 and needs 3:1 against its own ground.                                                                                                                                     |
+| The `theme-color` meta tag                                  | Section 12.                                                                                                                                                                                                                       |
+
+The distinction in one sentence: **the brand mark is fixed, the brand as an interface colour is
+theme-aware.**
+
+---
+
+## 10. Who may declare a colour, and how each layer reads the tokens
+
+Four layers. Exactly one of them is allowed to contain a colour value.
+
+| Layer                                                           | May declare a colour?  | Job                                                                           |
+| --------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------- |
+| **Token layer** — `src/styles/tokens/_light.scss`, `_dark.scss` | **Yes, and only here** | Declare every `--ot-*` for one theme. Two files, two blocks, no logic.        |
+| **Material theme** — `src/theme.scss`                           | Transitional           | M2 palettes stay until section 3 step 4 retires them. No new colour goes in.  |
+| **Shared SCSS** — `src/styles/common`, `src/styles/mixins`      | No                     | Mixins and layout. Colour only through `var(--ot-*)`.                         |
+| **Component SCSS and templates**                                | No                     | Consume `var(--ot-*)` or a Tailwind utility. A new hex is a review rejection. |
+
+### Tailwind
+
+Tailwind must **map** to the tokens, never restate them. Tailwind 4 is in use with a v3-style
+JS config loaded through `@config '../tailwind.config.js'` from `src/styles.scss` and
+`src/tailwind-intellisense.css`. Extend the theme with `var()` references:
+
+```js
+// tailwind.config.js
+theme: {
+  extend: {
+    colors: {
+      'formatif-blue': '#3939ff',                    // brand mark, stays literal
+      'formatif-blue-lighter': '#e7e7ff',
+      page: 'var(--ot-color-page)',
+      surface: 'var(--ot-color-surface)',
+      'surface-raised': 'var(--ot-color-surface-raised)',
+      content: 'var(--ot-color-text)',
+      muted: 'var(--ot-color-text-muted)',
+      border: 'var(--ot-color-border)',
+      divider: 'var(--ot-color-divider)',
+      primary: 'var(--ot-color-primary)',
+      link: 'var(--ot-color-link)',
+      success: 'var(--ot-color-success)',
+      warning: 'var(--ot-color-warning)',
+      error: 'var(--ot-color-error)',
+      info: 'var(--ot-color-info)',
+    },
+  },
+},
+```
+
+`bg-surface text-content border-border` then resolves per theme with **no dark variant on a
+colour utility**. Tokens are the default and they stay the default. A `dark:bg-x` on a colour
+doubles every class list and moves the palette back into templates, which is the thing this
+contract exists to stop.
+
+#### The dark variant is configured and pointed at the marker — THM-F03
+
+`darkMode` is not set today, and leaving it unset is **not** the same as having no dark variant.
+Tailwind's default `dark:` is `@media (prefers-color-scheme: dark)`. So the prefix already works
+in this build, and it already **follows the operating system and ignores the stored
+preference**. A `dark:bg-black` written today stays dark for a user whose preference is `light`,
+which breaks rule 2 of section 4 without anyone editing a line of this contract.
+
+Configuring it is a correctness fix before it is a feature. **THM-F03 binds the variant to
+`data-ot-theme`**, the marker section 5 fixes, so the app has one dark authority and it is the
+resolved theme:
+
+```css
+/* src/styles.scss, beside the existing @config line */
+@custom-variant dark (&:where([data-ot-theme='dark'], [data-ot-theme='dark'] *));
+```
+
+Tailwind here is `4.3.1` driven by a v3-style JS config through `@config` (`src/styles.scss:7`
+and `src/tailwind-intellisense.css:2`), so the legacy
+`darkMode: ['selector', '[data-ot-theme="dark"]']` may be honoured as well. **THM-F03 picks one
+form, proves it with a build, and records which.** Two mechanisms for one variant is how they
+drift apart.
+
+Three rules bound what the variant is then for.
+
+1. **Never for a colour that already has a token.** `bg-surface` flips on its own.
+   `dark:bg-[#201f23]` is a rejection and so is `dark:bg-surface-raised`.
+2. **Yes for what a custom property cannot carry.** A different asset, `dark:invert` on the PDF
+   viewer canvas, a border width, an opacity, a `mix-blend-mode`, a shadow that has to change
+   shape rather than colour. Section 7 has no token for these and never will.
+3. **Yes for third-party CSS the token layer cannot reach**, where a `dark:` utility carried by
+   `important: true` is a smaller intervention than forking a vendor stylesheet.
+
+`dark:` on a colour utility is a lint violation (rule 2 below). `dark:` on anything else is
+allowed and needs no exception.
+
+`important: true` stays as it is. It is what lets a utility override Material today, and
+changing it is out of scope.
+
+### Shared SCSS
+
+```scss
+// src/styles/mixins/_surface.scss
+@mixin card-surface {
+  background-color: var(--ot-color-surface, #ffffff);
+  color: var(--ot-color-text, #212121);
+  border: 1px solid var(--ot-color-border, #8a8a8a);
+}
+```
+
+The fallback is the current light value. That is the rollback guarantee from section 3, applied
+at every call site.
+
+### Material
+
+Material components are pulled onto the tokens with `mat.theme-overrides()` inside the same two
+theme blocks, one component group per PR:
+
+```scss
+:root[data-ot-theme='dark'] {
+  @include mat.theme-overrides(
+    (
+      surface: var(--ot-color-surface),
+      on-surface: var(--ot-color-text),
+      outline: var(--ot-color-border),
+    )
+  );
+}
+```
+
+### The three lint rules that hold the line
+
+These belong in MG-05 and are proposed here so the reviewer knows what to enforce until then.
+
+1. No hex, `rgb()` or `hsl()` literal in any file under `src/app`. Grep gate in CI, allowlist the
+   token files and `theme.scss`.
+2. No Tailwind arbitrary colour value, and no `dark:` prefix on a colour utility.
+   `eslint-plugin-tailwindcss` is already wired (`eslint.config.js:89-101`); add
+   `tailwindcss/no-arbitrary-value` for colour utilities, and a grep gate for
+   `dark:(bg|text|border|fill|stroke|ring|outline|from|via|to)-`.
+3. No new `--ot-*` name without an entry in section 7 or 8 of this document.
+
+Today's baseline for rule 1 is **226** literals in component SCSS under `src/app` plus 20 Tailwind
+arbitrary colour values across 9 template files, measured on `11.0.x` at `4034e7d1a`. That number
+is the migration burn-down and should only go down.
+
+**Re-measure it before quoting it.** This baseline read 105 when it was first taken, against
+`efda57967` at 09:24 the same morning. One closure merge landed at 22:00 and it became 226. The
+figure more than doubled inside thirteen hours, so a burn-down target copied out of this document
+a week from now will be wrong. THM-M01 should re-run the command on the commit it actually
+branches from and record that figure on its own card rather than inheriting this one.
+
+---
+
+## 11. Startup and live system changes
+
+### Live OS changes
+
+`ThemeService` holds one listener for the whole app lifetime. It fires only when the stored
+preference is `system`.
+
+```ts
+private readonly query = window.matchMedia('(prefers-color-scheme: dark)');
+
+constructor() {
+  this.query.addEventListener('change', () => {
+    if (this.preference() === 'system') {
+      this.applyResolved();
+    }
+  });
+}
+```
+
+- `addEventListener('change', ...)`, not the deprecated `addListener`.
+- Registered once, in a root-provided service, and removed on destroy. One listener, not one per
+  component.
+- No reload, no re-render of the router outlet. Changing `data-ot-theme` repaints via CSS.
+- Charts and Monaco cannot repaint from CSS, so the service also emits a signal that those
+  components subscribe to in order to re-read their colours. That subscription is part of
+  THM-M04, and this is the hook it uses.
+
+### No-flash startup — THM-F04
+
+Two cards touch startup. THM-F01 builds the foundation, which includes System detection and
+persistence, and THM-F04 specifies and implements the no-flash mechanism. They overlap, so the
+split is fixed here: **THM-F04 writes the `` script below and the drift test in section 14
+item 15. THM-F01 consumes whatever that script already set, at bootstrap, and owns the theme
+from that point on.** Neither card writes the other's half.
+
+The wrong theme flashing for one frame on every page load is the single most visible defect a
+theme feature can ship. Angular bootstraps after `` paints, so the marker has to be set
+before that. One small blocking inline script in `` of `src/index.html`, before any
+stylesheet:
+
+```html
+
+```
+
+Constraints on that script, all reviewable:
+
+- **Inline and synchronous.** A deferred or external script paints late and the flash returns.
+- **Under 400 bytes, no dependencies**, so it cannot meaningfully delay first paint.
+- **The whole body is inside `try`.** Safari private mode throws on `localStorage.getItem`.
+  Anything thrown here would block the app from booting at all.
+- **It writes only `'light'` or `'dark'`.** The stored value never reaches `setAttribute`
+  directly. This is the same allowlist as section 6, restated in the one place that runs before
+  the service exists.
+- **It duplicates the storage key as a literal.** That is deliberate: it runs before any module
+  loads, so it cannot import the constant. THM-T01 must assert the literal in `index.html`
+  matches `THEME_STORAGE_KEY` in the service, or the two will drift.
+- `d.style.colorScheme = t` makes native scrollbars, `