Repository navigation
feat(theme): reland dark mode and the page redesign on 11.0.x - #280
Merged
Merged
Conversation
…agement passport Register the theme tokens as Tailwind colours (bg-ot-surface, text-ot-muted, border-ot-divider, ...) so literal colour utilities can be swapped for token-backed ones that flip with data-ot-theme, no dark: variant needed. First use: the engagement passport card that showed as a white slab on the dark dashboard.
…me tokens Complete the per-screen dark-mode migration (THM-M02 / THM-M04) and the browser chrome (THM-W01): - ~600 hard-coded colour swaps across ~90 screen files: student dashboards, task and comment views, unit editors, modals, viewers and calendars now use the bg-ot-*/text-ot-*/border-ot-* Tailwind utilities and var(--ot-*) SCSS tokens, so they flip with data-ot-theme. - ThemeColorService resolves --ot-* tokens to concrete colours for ngx-charts and other JS that cannot consume a CSS var. The status pie, task-status cards and burndown series read status/chart tokens; the pie re-renders on a live theme flip. - THM-W01: the browser/PWA address-bar colour follows the resolved theme. Web suite 800 pass 0 fail, lint clean, build green on Node 22.
…olish Re-base dark mode on a GitHub-blue ground and finish dark-mode coverage and polish across the app: - Dark tokens re-based on a GitHub-blue ramp; softened light-mode whites - Vivid, AA-verified status colours; the task-status card wraps in its status colour (solid header, tinted body, coloured border); the status header icon is enlarged with tighter padding - Outlined buttons given a clear control fill and border so they read as buttons in both themes - ngx-gantt Task Planner themed dark by overriding --gantt-color-* on the <ngx-gantt-root> host (its inline light defaults beat a :root rule) - Reusable f-empty-state component; empty states added to 21 tables and the timeout/unauthorised pages centred and tokenised - Portfolio wizard fills the viewport with tightened spacing; body fills the viewport so short pages no longer show a page-colour seam - Global keyboard :focus-visible ring and prefers-reduced-motion support - Even, comfortable header icon-button and avatar hover targets - Remaining screens/charts migrated to the --ot-* theme tokens
The profile form's main container used justify-center on a full-width flex row.
When the content was wider than a phone, centring pushed the overflow off BOTH
edges, so headings clipped on the left ('otification Settings') and fields bled
off the right. Switch to justify-start and let the First/Second Name pair stack
below the small breakpoint. Verified at 390px in both themes: no clipping, no
horizontal page scroll.
The task Details sub-tabs centred their overflowing strip, which clipped the
active first and last labels at the viewport edges ('ask Details' /
'Submissic'). Left-align the strip and drop the auto margins so the active tab
stays whole and the rest scrolls into view. Verified at 390px in both themes.
The unit switcher menu was pinned to a hard 400px, wider than a phone viewport; cap it to calc(100vw - 16px). And the global 2px outward focus ring was clipped by menu/option panel edges into two harsh bars on the focused row; inset it for menu items and options so it frames the row. Verified at 390px in both themes.
The global keyboard focus ring uses :is(a[href], ...):focus-visible, which lands at (0,2,1) specificity because of the a[href] arm, so the earlier menu-item inset rule (0,2,0) never won and the ring stayed a clipped pair of bars. Scope the inset under the panel ancestors to clear it. Verified under keyboard focus in dark: the ring now frames the focused row.
…stence THM-F01. Adds the --ot-* design-token layer and the ThemeService that resolves and persists the user's theme choice. - Token layer: src/styles/tokens/_light.scss and _dark.scss declare every --ot-* token, light as the bare :root default and dark under [data-ot-theme=dark]. These two files are the only place a colour value is declared. Wired into styles.scss. - ThemeService: preference (light/dark/system) vs resolved (light/dark), system detection via matchMedia, persistence in localStorage under the ontrack.theme.preference key, writes the data-ot-theme marker and colorScheme. Junk or blocked storage falls back to system, never a hard dark. One matchMedia listener, removed on destroy. - Proof page at /theme-demo demonstrating the section-13 component-state matrix and the token set, repainting in both themes with no reload. Out of scope by contract: the accessible toggle (THM-F02), the Tailwind dark variant (THM-F03), the no-flash <head> script and its drift test (THM-F04), and the theme-color browser chrome (THM-W01). Built against doubtfire-web 11.0.x @ 0ba9fd7. 17 unit tests pass, ng build clean, eslint --max-warnings 0 clean.
Build the app shell and shared components on top of the THM-F01 token foundation so they follow the resolved-theme marker, with no visual change in light mode. - Dark Angular Material M2 theme keyed to :root[data-ot-theme='dark'], built from a desaturated indigo palette (500 = #5457e5) so filled buttons, the fab and progress bars read as one accent with white labels (white measures 5.4:1). Repoint card, menu, dialog and select container colours onto the raised surface token via the --mat-* variables. - Rework the dark token ramp to a cohesive cool-slate palette with a single indigo accent (was warm greys sitting too close together). - Bind Tailwind's dark: variant to the marker (THM-F03). - Add a single-button theme toggle that flips light and dark from the resolved theme (THM-F02), registered in the module and placed in the header; eagerly inject ThemeService in AppComponent so the marker is stamped on every device, not only where the toggle renders. - Migrate hardcoded colours to tokens in the header, home dashboard, the f-chip shared component, the edit-profile form, the hero sidebar, and the two places that set the body background inline in JS. Each token resolves to the original light value and carries a legacy var() fallback. Tests: theme service 17, toggle 2, app/f-chip/header/edit-profile updated and green. Lint clean at --max-warnings 0.
manifest background_color was #ffffff, which the OS paints as the launch splash of an installed PWA before any app code runs. A dark-mode user got a full-screen white flash on every launch. The manifest is static and read at install time, so it cannot follow the theme. Set background_color to the brand indigo #3939ff, which already matches theme_color, so the splash is a solid brand panel behind the icon in both light and dark. theme_color stays #3939ff.
Map the server theme_preference field onto the User model and let the
account value win on sign-in.
- user.ts: add themePreference ('light' | 'dark' | 'system'), registered
in the UserService mapping so it maps from theme_preference.
- ThemeService.applyServerPreference: the server value wins on sign-in and
is written straight back to local storage so the no-flash pre-boot script
agrees on the next load. Gated by isThemePreference, so a missing or junk
value is ignored and the local choice stands.
- Wired in setupUserFromResponse once currentUser is set, on both fresh
sign-in and the refresh-token path.
Resolution order: server wins on sign-in and persists locally; sign-out
leaves the stored value in place, not light; and the matchMedia handler
only ever repaints, so an OS appearance flip while System is selected
never writes anywhere. Only setPreference, a user action, writes.
Swap the hard-coded colours the M01 shell work left behind to the --ot-* tokens so the shell and global list surfaces flip in dark mode: - notification dropdown message, time, stale and empty text, the unread dot, and the bell badge cut-out ring - task-dropdown breadcrumb icons (were hard black on the dark toolbar) - footer action bar and its status-chip resting and disabled states - the global selectable-table row hover (.f-table and .table-selectable) - the group-selector row hover Footer bar uses --ot-color-page so it keeps contrast with the white status buttons in light mode. Tokens verified present in both palettes. Web suite 800 pass 0 fail, lint clean, build green on Node 22.
…tokens Retarget the runtime-themed surfaces the shell PR did not cover so they flip with data-ot-theme: - status-icon chips read var(--ot-status-<key>) and the AA-fixed var(--ot-status-<key>-on) instead of the compile-time light-only map, so every task-status chip across the app themes correctly - footer status chips drive --status-chip-bg from the same token - grade/quality task-list badges invert against the list surface (--ot-color-inverse-surface/-inverse-text) instead of a black circle that vanished on the dark list - custom scrollbars and the global progress spinner use border/divider tokens Note: adopting the contract's -on foregrounds shifts a few status labels in LIGHT mode too (e.g. working-on-it goes to dark text on amber), which is the contract's WCAG AA choice, not a regression. The status colour used inside the d3/canvas charts still reads the light-only TS map and is a separate follow-up. Build, lint and web suite (800 pass, 0 fail) green on Node 22.
…agement passport Register the theme tokens as Tailwind colours (bg-ot-surface, text-ot-muted, border-ot-divider, ...) so literal colour utilities can be swapped for token-backed ones that flip with data-ot-theme, no dark: variant needed. First use: the engagement passport card that showed as a white slab on the dark dashboard.
…me tokens Complete the per-screen dark-mode migration (THM-M02 / THM-M04) and the browser chrome (THM-W01): - ~600 hard-coded colour swaps across ~90 screen files: student dashboards, task and comment views, unit editors, modals, viewers and calendars now use the bg-ot-*/text-ot-*/border-ot-* Tailwind utilities and var(--ot-*) SCSS tokens, so they flip with data-ot-theme. - ThemeColorService resolves --ot-* tokens to concrete colours for ngx-charts and other JS that cannot consume a CSS var. The status pie, task-status cards and burndown series read status/chart tokens; the pie re-renders on a live theme flip. - THM-W01: the browser/PWA address-bar colour follows the resolved theme. Web suite 800 pass 0 fail, lint clean, build green on Node 22.
…olish Re-base dark mode on a GitHub-blue ground and finish dark-mode coverage and polish across the app: - Dark tokens re-based on a GitHub-blue ramp; softened light-mode whites - Vivid, AA-verified status colours; the task-status card wraps in its status colour (solid header, tinted body, coloured border); the status header icon is enlarged with tighter padding - Outlined buttons given a clear control fill and border so they read as buttons in both themes - ngx-gantt Task Planner themed dark by overriding --gantt-color-* on the <ngx-gantt-root> host (its inline light defaults beat a :root rule) - Reusable f-empty-state component; empty states added to 21 tables and the timeout/unauthorised pages centred and tokenised - Portfolio wizard fills the viewport with tightened spacing; body fills the viewport so short pages no longer show a page-colour seam - Global keyboard :focus-visible ring and prefers-reduced-motion support - Even, comfortable header icon-button and avatar hover targets - Remaining screens/charts migrated to the --ot-* theme tokens
The GitHub-dark ground (#0d1117 page) read as near-black and too harsh. Lift the whole neutral ramp to a softer slate — page #21262d, surface #2b313b, raised #353c47 — with matching border/divider, a softened text #dbe3ea and a lighter muted #a4afbd and link #6cb2ff. Accents, status fills and their -on inks are unchanged. Contrast re-checked: body text 8.6-11.7:1, muted and link 5.0:1 on the lightest surface, primary fill 4.63:1 — all clear WCAG AA. Elevation still reads by lightness (page->surface->raised).
In dark, --ot-elevation-1 is near invisible on the #21262d ground, so cards, dialogs, menus and select panels separated only by an ~18-point lightness step read flat against the page. Add a hairline --ot-color-border (#464f5b) edge to each so they lift the way a drop shadow does in light. The outlined-card outline was pointed at --ot-color-divider, which equals the raised surface in dark (#353c47) and drew nothing; repointed to the border token. The Web calendar dialog and home unit cards now have a defined boundary.
…urface distinction
Introduces a theme-independent radius scale (styles/tokens/_shape.scss:
--ot-radius-xs/sm/md/lg/pill/circle) and the matching Tailwind rounded-ot-*
utilities, then collapses the ~20 ad-hoc radii that had accreted across the tree
onto it. Cards/panels -> md (12px), controls/inputs -> sm (8px), progress track
-> pill; the intentional asymmetric chat-bubble corners are left as a documented
exception.
- home: unit cards 15px -> md; footer progress bar rounded to a pill
- cross-dashboard: unit column 8px -> md, five hand-rolled inputs/buttons -> sm
- calendar modal: two nested panels -> md
- header: notifications dropdown 16px -> md; unit switcher pinned to md so the
two adjacent menus match
- shared: .f-table 0.8rem -> md; unit-task-list row 6px -> sm; drop-zone -> sm
Dark surface distinction, the plain (non-Material) surfaces the central card rule
in theme.scss does not reach:
- cross-dashboard unit column gets a dark-only border-ot-border edge
- calendar tab panel switched from the divider token (equals the raised surface
in dark, so invisible) to the border token
- profile settings separator uses the border token in dark, divider in light
- .f-table gets the dark hairline edge
- due-date chips gain dark variants so they stop reading as light pastels on the
dark ground
…contrast - home: the enrolled-units action buttons were in an h-60 justify-around column so View all / View previous floated far apart; stack them top-aligned with a gap. Unit-code subtitle pinned to the muted text token (Material's default read too faint for AA). - calendar modal: size the dialog to its content (the open config passed the Tailwind class 'h-min' as a CSS height, which Material dropped, leaving the disabled state a tall near-empty box). Only fill+scroll when enabled. Close button dropped its warn (red) colour — it is a neutral dismiss. - confirmation modal: Cancel dropped its warn colour (red reads as destructive) and is a neutral stroked button beside the primary Confirm. - dialogs follow the 12px surface radius (Material default 4px was too tight). - inactive tab labels pinned to the muted text token so the For Google/Apple/ Outlook strip clears AA in dark.
The unit card clipped its own content: a fixed max-height 200px with overflow hidden, plus a title that set text-overflow:ellipsis alongside white-space:wrap (so it never truncated, it wrapped to 3-4 lines). A long unit name pushed the term chip and progress bar out of the card. Clamp the title to two lines with a real ellipsis and drop the hard max-height; the title's min-height keeps every card a uniform height. Also remove the mid-card divider under the unit code, which read as an empty text input. calendar modal: the reminder Save action was a bare icon button that did not read as a button; make it a labelled mat-flat-button (Save) with a stroked Cancel.
The active tab used the mid primary blue, which read dim on the dark panel. Point the active label and indicator at the brighter link token so the selected tab pops in both themes.
The Material dark paginator kept a warm-grey container that read as a stray band against the slate ground (visible under the View-all units list). Point it at the page token so it matches the rest of the design.
…right
The earlier attempt set --mat-tab-header-* tokens, which this Material build does
not read (verified: active label stayed the mid primary #1f6feb). Switch to the
--mat-tab-{active,inactive}-label-text-color tokens it actually reads, and because
the theme mixin re-sets the active token inside the dark scope at higher
specificity, force the active label and ink bar to the link token directly. Active
tab now computes #6cb2ff.
The unit column header used a full-saturation accent band (bg-ot-primary / green-600). Against the slate ground in dark it read as a loud block, heavier than the task content below it. Route the header colour through a class and, in dark, sit it on a deep low-brightness tint of the accent (mixed 55% toward the page token) so the header belongs to the slate family instead of shouting over it. White labels still clear AA (~7.7:1 blue, ~6.5:1 green). Light keeps the saturated band, which reads fine on the near-white page.
The comments panel closes full screen on Escape, so it is now a labelled region that can hold focus, as the 11.0.x interactive-supports-focus rule requires.
The redesigned cards named each title link by its code alone. The link now reads the code, the unit name and the role, and the progress bars name the unit again, as the 11.0.x accessibility work had them.
…he scrim token These shadows were fixed black at 18 to 28%, which barely shows on the dark surfaces. The scrim token is darker in dark mode, the same way the project dashboard panels already use it. The onboarding backdrop stays light because the tour highlights page elements through it.
The merge dropped canSelectNullableOptions from the mentor select, so No mentor could not be picked and a mentor could not be cleared. It also put the web calendar switch back on (click), which fires for clicks that do not toggle. Both now match 11.0.x, and the staff editor spec checks the first.
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
This relands #192, dark mode and the redesign of the student and staff pages, on current
11.0.x. #192 closed unmerged on 19 Sep when PR intake closed, so none of it reached11.0.xor the upstream PRs to thoth-tech. Without it the pages added since then have no dark theme. Course Flow for example shows dark text on white cards in dark mode.How it was built
bd0ade6e0. That already holds fix(mobile): profile form clips its own headings on phones #195, fix(mobile): task sub-tab strip clips its active label #198 and fix(theme): contain unit switcher width + inset menu focus rings #200, Maple's reconciled mobile work (18b54cdaf,86cf018b2) and Maple's two fixes from the feat(theme): dark mode and a redesign of the student and staff pages #192 reviews (db6e6e959node_modules link,070ffae78group_set_id).11.0.xatdf118f868into it in69b89603f. 144 files conflicted.11.0.x(features, bug fixes, API clients, accessibility labels and the specs that check them), and keep the redesign's layout and styling on top. Where both sides re-themed the same element I kept the redesign's look and11.0.x's contrast and focus fixes.dashboard.tpl.htmlandtask-dashboard.tpl.htmlwere changed by the redesign but deleted on11.0.xwith no replacement, so they stay deleted.Dark mode for the features added after #192
var(--mat-sys-*, <light colour>). Nothing in the app defines--mat-sys-*, so the light fallback always won. They use the--ot-color-*tokens now.--ot-color-borderis#798698, so borders clear 3:1 on raised cards too (3.01, the old value gave 2.87).Fixes from the merge
11.0.x's accessible names, and the unit links keep them with the visible unit code in front, so the name starts with what's on screen.interactive-supports-focuslint rule wants.canSelectNullableOptions), with a spec that fails without it.toggleChangeagain, as on11.0.x, not for any click.Behaviour changes to know about
86cf018b2), so the per-file confirm dialog from11.0.xisn't used any more.docs/safe-upload-and-chat-guide.mdanddocs/FILE-A01-safe-upload-policy-matrix.mdstill describe the confirm step and need updating.DemoDataMaskInterceptoris gone. Maple's demo scenario registry (86cf018b2, reconciled in18b54cdaf) replaces it.Built against
theme/redesign-reland@cab60d1d3, on11.0.x@df118f86811.0.x@5de649d7511.0.x@1ca0413b5Scope
715 files, +54,224 / -17,232 against
11.0.x, and 263 non-merge commits ahead of it. Nearly all of that is #192 as it was reviewed. The merge itself and the fixes after it are the last 7 commits.Testing
On Node 22 at
cab60d1d3:npm run test:ci: exit 0, 272 files, 2125 tests passednpm run lint: exit 0npm run typecheck: exit 0npx ng build: exit 0I tested every page in light and dark myself before #192 closed. My 14 Sep comment on #192 said the light and dark checks were still to do. That was wrong, sorry for the mix up.
On this head I went through it again on the local demo stack and took screenshots in both themes: 18 student and staff pages at desktop width, 6 at tablet width (820px), 9 at phone width (390px), and home, dashboard, Students and the inbox at 320px. They're in the first comment below. That covers the phone, tablet and desktop light and dark evidence Maple asked for on #192.
Known open items
11.0.x(N/A there, "Unnamed student" here). It happens without this branch too, so I've left it for its own fix.Relationship to other PRs