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,`, 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, `