feat: site-wide dark mode (DoenetML 0.7.21) with accessibility tests#3010
feat: site-wide dark mode (DoenetML 0.7.21) with accessibility tests#3010dqnykamp wants to merge 37 commits into
Conversation
Bring the lagging declared ranges up to the latest published stable so the whole engine chain matches: @doenet/doenetml-iframe ^0.7.21 in apps/web (was ^0.7.20) and @doenet/v06-to-v07 ^0.7.21 in apps/app (was ^0.7.19). apps/app's doenetml-iframe and assignment-viewer were already at latest. 0.7.21 is the version that completed dark-mode support in the rendering engine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
Adds a tri-state theme setting (System/Light/Dark) chosen from the user's
avatar menu that drives both the Chakra site chrome and the DoenetML
viewer/editor components' darkMode prop.
Foundation
- New apps/app/src/utils/theme.ts: MIN_DARK_MODE_DOENETML_VERSION (0.7.21),
effectiveDarkMode() (forces light for pre-0.7.21 docs whose CDN engine
predates dark-mode CSS), the useThemeSetting hook (tri-state state, Chakra
colorMode sync, live prefers-color-scheme listener, localStorage + server
persistence), and a ThemeSettingProvider/useThemeSettingContext React context
(not shadowed by the editor's nested router outlet).
- theme.ts: migrate background/surface/interact/border/text/accent and the
theme-sensitive doenet.* colors to semanticTokens with {default,_dark}; add
surfaceMuted/textMuted; make the outline Button variant mode-aware.
- index.html: inline pre-paint FOUC script (resolves system, sets
html[data-theme]+colorScheme, pre-seeds chakra-ui-color-mode); html follows
--canvas; keep the [data-theme="dark"] CSS-var block (now activated).
Control + persistence
- Avatar menu (AccountIconAndCard) gains a System/Light/Dark radio; threaded
through Navbar -> NavbarDesktop/NavbarMobile.
- Server: users.theme column (+migration), setTheme query/route/zod schema,
theme added to getMyUserInfo; client persists to localStorage always and to
the DB when logged in (DB wins on load).
Viewer wiring
- Pass darkMode={effectiveDarkMode(setting, version)} to the singleDoc/editor
render sites; pass the raw setting to the 4 compound-activity sites (per-leaf
gating belongs inside DoenetActivityViewer). RawViewer (/embed) stays system.
Full sweep
- Replace hardcoded white/black/gray Chakra literals and theme-sensitive hex
across pages/widgets with semantic tokens; add a Google sign-in dark variant.
Intentional white/black text on fixed colored banners/buttons is preserved.
Verified: app+api typecheck clean, production build succeeds, new setTheme test
passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
The navbar logo's "Doenet" wordmark is black and vanished against the dark chrome. Add a dark variant of Doenet_Logo_Frontpage_color_small_text.png with the wordmark recolored to the dark-mode body-text color (#e8e8e8); cloud + donut unchanged. RouterLogo swaps to it via useColorModeValue. The SignIn logo sits on its own light-blue circle and reads fine in both modes, so it is left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
Chakra's default subtle Alert kept a light status-colored background while inherited text went light in dark mode, so warnings/infos (e.g. the Scratch Pad "changes are not permanently saved" banner) rendered as light text on a light background. Override the Alert subtle variant to pin readable bg/text/icon per color scheme in both modes via _dark. Light mode is unchanged; fixes all seven <Alert> banners app-wide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
The hardcoded light-orange (orange.100) background used for warning strips stayed light in dark mode, leaving light inherited text illegible on it. Add a _dark override (orange.900 bg, lighter borders) to all four occurrences — the Scratch Pad header bar, the unlisted-content banner, and the two "shared without a license" notices — matching the Alert dark-mode scheme. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
…hemes
Scanned the editor Settings page and fixed illegible dark-on-dark / light-on-
light spots, plus the same schemes wherever they recur site-wide:
- FormLabel color toggles color={... "black" : "gray"} -> text/textMuted so the
labels flip (EditAssignmentSettings, EditLicense, DoenetMLVersionComponents).
- background="lightgray" -> surfaceMuted (flips) on the license card, search-
results headings, and related spots (Licenses, Activities, LibraryActivities);
the license card border tracks the border token.
- #666699 category/label icons -> new iconAccent semantic token (lightened in
dark) across EditCategories, Card, FilterPanel, GeneralContentInfo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
Page-level accessibility (packages/e2e-tests/e2e/Accessibility/basicTests.cy.ts) now runs in both light and dark: the suite is parameterized over color modes and sets doenet-theme-setting in localStorage before each page load, so the whole page (chrome + DoenetML viewers) renders in the target mode before checkAccessibility scans it. Also make component tests color-mode-aware: cy.mount accepts a colorMode option and honors a `colorMode` Cypress env, pinning Chakra's color mode via a fixed ColorModeManager, so the component a11y suite can likewise be run in dark (`--env colorMode=dark`). Verified against the running app: dark Explore passes axe; the run also surfaced a pre-existing color-contrast violation on My Activities (.chakra-text) to fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
… suite Two dark-mode fixes surfaced by running the page a11y suite with the OS color scheme emulated: - FolderContext active nav buttons (My Activities / Shared / Trash) used a fixed doenet.lightBlue background with ghost-button text that flips near-white in dark mode -> 1.5:1 contrast (axe SERIOUS). Pin dark text on the active item. - FilterPanel disabled category checkboxes were dimmed to Chakra's default 0.4 opacity (hard to read, worse in dark); raise to 0.7 so they stay de-emphasized but legible. Also fix the dark-mode a11y test mechanism: the site theme defaults to "system", so a logged-in user's stored DB theme overrode a localStorage setting and the real mode followed the browser's preference. Drive the mode by emulating prefers-color-scheme (Emulation.setEmulatedMedia) instead, so the light/dark passes are deterministic. (This is why CI — whose browser prefers light — stayed green: the near-white-on-lightBlue bug only shows when system resolves to dark.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
The activity/explore card row hover set a fixed light background (#eeeeee / "ffffff" — the latter also missing its #), which became white text on a near-white background in dark mode. Use semantic tokens (interact/surface) so the hover flips with the color mode. Add accessibility tests that real-hover a card row on My Activities and Explore (via cypress-real-events, so the CSS :hover state actually applies) and run checkAccessibility. These fail on the pre-fix code in dark mode (SERIOUS color-contrast, white-on-white) and pass after the fix; they run in both light and dark via the parameterized suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
- RawViewer (/embed): apply the version guard (effectiveDarkMode("system", ...))
so a document pinned below 0.7.21 renders light instead of defective dark,
while newer docs still follow the host/OS.
- ActionBar: the selection bar background used a fixed gray.100 (via a ternary
the earlier sweep's string match missed) -> light text on light in dark mode;
use the flipping surfaceMuted token.
- index.html: lighten --mainBlue in the [data-theme="dark"] block (#1a5a99 ->
#6ca0dc); it's a text/link color (e.g. the "< Back" link on the activity
viewer) that was 1.97:1 on dark backgrounds.
- Move cypress-real-events from devDependencies to dependencies (it's imported
from a support file; import/no-extraneous-dependencies failed CI lint).
Fixes the failing `check` (lint) and `e2e-tests (group1)` dark a11y jobs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbN5g9KCNDAQrXvvAYS5gw
- theme.ts FormError: red.700 -> red.300 in dark mode - CreateDocumentSavePoint/DocumentComparisonControls/SetDocumentToSavePoint status boxes: dark-aware green/red bg+text, border token, dark error icon - add harness-driven dark a11y tests that render the status boxes - add test:*:dark npm scripts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
library.tsx PENDING/UNDER_REVIEW/PUBLISHED used dark CSS-named/orange.600 text unreadable on dark surfaces; add _dark light-tint variants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ShareModal: dark-aware requirements card heading, criterion rows, blocking- documents box (bg/border/icon/headline/links), unsaved-visibility note, and visibility option cards - ShareButton: dark variants for visibility pills (were near-white in dark) - add cy.checkContrast command (computes WCAG ratio for modal-nested text that axe reports as 'incomplete') + dark contrast/luminance tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AddClassificationModal: added-card fill lightGray (CSS named) -> doenet.* semantic tokens so it flips - EditImageAttribution: dark-aware selected LicenseCard fill/border/icon and dark error text - checkContrast: composite semi-transparent (whiteAlpha) fills/text so translucent Chakra button backgrounds aren't false-flagged - add dark contrast tests for both modals Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…image details Page-level components (loader/outlet-context, no component-test harness): - CompoundActivityEditor: white canvas bg -> 'background' semantic token - SharedActivities: license footer gray/canvas -> surfaceMuted/text tokens (was low-contrast in both modes) - ImageDetails: blue.600 links/back button get _dark blue.300 variants Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s icon Invisibility bugs (axe can't catch borders/icons; page-level, no harness): - FolderContext: side-panel divider 'solid 2px black' -> border token - Activities: root 'My Activities' icon color black -> text token Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orders - WithSideBanners: default bgColor 'white' -> 'background' semantic token - Home: video frame borders 'lightgray' -> border token Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- checks.yml: add color-mode matrix dimension (light, dark) to component-tests so contrast regressions are caught in dark mode (runs in parallel) - theme.ts: pin Button solid-blue _active bg (open MenuButton fell back to Chakra's dark default blue.400 -> 3.05:1 with white text) - DocEditorHistoryMode.cy.tsx: give the mock editor explicit dark text so its fixed light fill isn't light-on-light in dark runs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hovered-row e2e a11y tests could sample a Chakra tooltip mid-fade, intermittently flagging a transitional low-contrast frame. Wait for the fade to finish before scanning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hecks Adding a color-mode matrix dimension renamed the jobs to 'component-tests (groupN, light|dark)', so the required 'component-tests (groupN)' checks never reported (stuck 'Expected'). Keep the light job's original names and put dark mode in a separate 'component-tests-dark (groupN)' job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the separate dark job in favor of the simpler 2D matrix; checks are named 'component-tests (groupN, light|dark)'. Branch-protection required checks should be updated to those names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
doenet.lightBlue (#b8d2ea) is a fixed brand color with no dark variant, so the full-page frame/gutter around the viewer stayed light blue around a dark canvas. Add a flipping 'viewerFrame' semantic token and repoint the frame sites (ActivityViewer, AssignmentViewer, EditorHeader, ScratchPad, DoenetMLComparison, BlueBanner, AssignmentItemResponseStudent) plus the FolderContext active-nav highlight to it. doenet.lightBlue stays fixed for its text/brand uses (footer link, WelcomeBanner). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
widgets/Card.tsx Unlisted/Public pills used fixed light fills (blue.50/green.50) that rendered as light pills in dark mode; extract a testable VisibilityPill component with dark-aware config. Add VisibilityPill.cy.tsx (luminance + checkContrast) — Card had no a11y test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WelcomeBanner used a fixed light-blue band (doenet.lightBlue) with black text, staying light-on-dark in dark mode; switch to the viewerFrame token (light blue → dark blue) with text/link colors that flip. Add WelcomeBanner.cy.tsx (checkContrast + background-luminance per mode). Also swap remaining hardcoded grays for the semantic `border`/`textMuted` tokens in Card (menu icon, card bottom border), NameBar (editable outline), and SearchBar (input border) so they don't render as fixed light lines/icons in dark mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
The solid blue button (colorScheme="blue" — New, Start writing, MenuButtons) had a fixed blue.600 fill in both modes. In dark mode that read as nearly the same blue as the fixed doenet.mainBlue section bands (e.g. the home-page newsletter strip) against the near-black page, so the primary action didn't separate from the surrounding blue. Add mode-aware buttonBlueBg / buttonBlueHover semantic tokens: light mode keeps blue.600 / blue.700; dark mode uses a slightly brighter #2d75bc (4.79:1 with white text, still AA) with hover landing on blue.600. IconButton inherits the same config. Add BlueButton.cy.tsx (per-mode fill luminance + checkContrast). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
This reverts commit 23c1407.
…tate Two dark-mode flaws the earlier sweep missed (both on routes/states the a11y suites never scan): - AssignmentResponseOverview's Statistics bar chart set no colors, so Recharts used its library-default grays (~Doenet#666 axis text, #ccc grid) that don't flip — dim, sub-AA axis text on the dark page. Extract a ScoreSummaryChart component that drives axis/label/grid from the textMuted/border token values via useColorModeValue. Add ScoreSummaryChart.cy.tsx (per-mode axis-fill luminance) and pre-bundle recharts in cypress.config.ts so the new spec doesn't trigger a mid-run Vite re-optimization (issue Doenet#2957). - CompoundActivityEditor's empty state used the fixed brand var(--lightBlue), which does not flip: a light-blue panel with light text on the dark page. Use the mode-flipping viewerFrame token instead, via an extracted compoundEditorBodyBackground() helper. Add CompoundActivityEditor.cy.tsx for the token choice; WelcomeBanner.cy.tsx already covers that viewerFrame flips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
…d pages
The audit found ~25 routes with no contrast coverage in either suite. Add
component specs (checkAccessibility, run in both color modes by the existing
matrix) for the pages that mount cleanly at the component level, without adding
to the large e2e scans:
- Static/simple pages: GetInvolved, Events, About, QuickLinks, SignIn.
- Data pages (empty state, exercising page chrome + muted empty-state text, a
flagged low-contrast offender): SharedWithMe, Trash, Curate (Curate also
covers the un-themed enclosed-colored Tabs).
To support these, extend cy.mount with two backward-compatible options:
`outletContext` (mounts the component under a route providing
`<Outlet context>`, for pages reading useOutletContext, e.g. `{ user }`) and
`loaderData` (a route loader for pages reading useLoaderData). Existing specs
that pass `action`/`routes` are unaffected (verified).
Pre-bundle react-icons subpaths in cypress.config.ts: the new paths/ specs sort
ahead of any widget spec that would otherwise warm react-icons first, and each
subpath bundles thousands of icon modules — a classic mid-run Vite re-optimize
trigger that flaked ~1-in-3 cold runs (component tests have no retries). With
them pre-bundled the trio is green across repeated cold runs (issue Doenet#2957).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
…ibility The site theme preference was stored as a free String @default("system") even though it only takes system/light/dark — unlike the other fixed-value columns (ContentType, Visibility, RecentMode, ...), which are Prisma enums. Follow that pattern: add a `Theme` enum and type the column with it, and use the generated `Theme` type from @prisma/client in setTheme() instead of a hand-written string union. The Zod setThemeSchema already uses z.enum([...]) like contentTypeSchema, and the app already models this as the ThemeSetting union, so no change needed there. Since this column is new in this PR, fold the change into the original add_user_theme migration so it adds the column as ENUM('system','light','dark') directly, rather than adding it as VARCHAR and converting it in a second migration within the same PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
2e776ad to
35fb95c
Compare
…kMode A document pinned to `dev` (fullVersion "dev") already resolved to true, but only incidentally — "dev" isn't numerically parseable, so it fell through to the unparseable-version fallback. Make it explicit alongside `latest` so `dev` is intentionally treated as newest/supported, and genuinely-malformed input still rides the separate NaN fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
VisibilityPill (and its getVisibilityPillConfig helper) lived in Card.tsx while its spec sat in VisibilityPill.cy.tsx. Match the established pattern of a component next to its Cypress test (WelcomeBanner, ScoreSummaryChart, ...): extract it to widgets/VisibilityPill.tsx, import it into Card, and point the spec at the new file. Pure move — no behavior change; Card drops the now-unused Badge and react-icons/fi imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
The client `UserInfoWithEmail` gained `theme?` (added when the theme setting was threaded through the frontend), but the server-side mirror in `apps/api/src/types.ts` — which the file's own banner requires to stay equivalent — was not updated. Add a `Theme` string-union (matching the Prisma-defined enum, like `Visibility`) and the `theme?` field so the two type modules match. Type-only; no runtime behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <!-- |
There was a problem hiding this comment.
This seems questionable. Is this how you're supposed to do it with Chakra?
There was a problem hiding this comment.
Consulting with Claude, the conclusion was that the questionable part was the hand-writing Chakra's private chakra-ui-color-mode key, which coupled us to a Chakra internal.
Added a custom colorModeManager (doenetColorModeManager) whose get() resolves our doenet-theme-setting, passed to ChakraProvider.
We still have the hand-rolled inline script, though it no longer uses the Chakra private key.
Here's Claude's rationale: an inline <head> script is the idiomatic way to prevent the FOUC — ColorModeScript is one — but the vanilla version only knows Chakra's own resolved light/dark key. Our source of truth is the 3-value doenet-theme-setting (also fed to the viewer's darkMode), and for system we need to re-resolve against prefers-color-scheme on every load; otherwise a stale resolved value flashes when the OS theme changes between sessions. Added a unit test for the manager's resolution logic.
|
Also, usually the theme selector is 3 inline options. Clicking one shouldn't close the account popup. |
|
The dark mode looks a lot harsher than the light mod and changes the tone of the website. We'll want to change the color palette to still feel inviting, but that can be a follow-up PR. |
I wonder if it's worth setting the default to light mode, just so new visitors don't get the the wrong idea ("this is a website for hard-core tech people"). |
…m the viewer
ThemeSetting is a plain "dark" | "light" | "system" union used site-wide for
the theme preference; sourcing it from @doenet/assignment-viewer coupled the
site's theme system to the viewer package for a foundational type. Define it in
utils/theme.ts. It stays structurally compatible with the viewer's darkMode
prop, so the `darkMode={...}` call sites still enforce the match (tsc passes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
…anager The pre-paint script wrote Chakra's private `chakra-ui-color-mode` key by hand to make Chakra hydrate correctly — coupling to a Chakra implementation detail. Instead give ChakraProvider a custom colorModeManager whose get() resolves `doenet-theme-setting` (handling `system` against the OS preference), so Chakra reads the site's source of truth directly. The inline script now only sets `data-theme`/`color-scheme` for the pre-paint CSS. Add a unit test for the manager's resolution logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
…open The theme picker used MenuItemOptions, which stacked vertically and closed the account menu on selection. Replace them with a ThemeSelector radio group — three inline options with icons (System = monitor, Light = sun, Dark = moon). Being a radio group rather than MenuItems, selecting an option no longer closes the menu. Add unit tests for the selector and an integration test asserting the menu stays open on selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSRrRnsCRHYLRkAFL253Z5
Fixed |
I'd rather keep the default at System so we don't have to change it up on people again. How can we make it less harsh? I can see what Claude does with that request. |
|
Here's a proposal that Claude made for softening the dark mode. |
Seems reasonable. The important part is changing the surface colors to be light enough and off-gray hued so they don't suck the energy out of the page. The colors it suggests are OK to start, but in a follow-up PR I would want to try a stronger change. |
…ents) Address PR feedback that dark mode reads harsh and drains the page's energy. Applies the approved follow-up palette: - Surfaces gain a faint blue tint and sit a hair lighter than flat neutrals (background/surface/surfaceMuted/interact/border, plus doenet canvas/grays) in both theme.ts semantic tokens and index.html's dark CSS-var block. - canvastext drops from pure white to #ededed to kill halation. - Brand accents (blue/red/green/yellow/purple) are desaturated + lightened in dark mode so they stop vibrating against the dark surfaces. - ScoreSummaryChart axis/grid track the new textMuted/border values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a site-wide dark mode to
apps/app, driven by a tri-state themesetting (System / Light / Dark) in the user's avatar menu, and upgrades the
DoenetML engine chain to 0.7.21 (which completed dark-mode support in the
viewer/editor components).
Highlights
Theme foundation
theme.ts:semanticTokenswith{ default, _dark }for the core palette(
background/surface/interact/border/text/accent,surfaceMuted,textMuted,iconAccent, and theme-sensitivedoenet.*), so token-based usages flipautomatically. Dark-aware Alert and Button variants.
index.html: pre-paint FOUC script (resolves system, setsdata-theme+colorScheme, pre-seeds Chakra's key) so there's no flash; keeps the[data-theme="dark"]CSS-var block active.utils/theme.ts:useThemeSetting(tri-state state, Chakra sync, liveprefers-color-schemelistener, localStorage + DB persistence), aThemeSettingProviderReact context (survives the editor's nested outlet),and
effectiveDarkMode()which forces light for docs pinned below DoenetML0.7.21 (their CDN engine predates dark-mode CSS).
Control + persistence
users.themecolumn (+ migration),setThemequery/route/zod schema,themeadded togetMyUserInfo; localStorage for anonymous + FOUC.Viewer wiring
darkMode={effectiveDarkMode(setting, version)}on the singleDoc/editorrender sites; raw setting on compound-activity sites.
/embedRawViewerstays
system(it's an external-iframe endpoint outside the theme tree).Full color sweep across pages/widgets (hardcoded
white/black/gray.Nand theme-sensitive hex → semantic tokens), a dark Doenet logo variant, and
targeted fixes for warning strips, the editor Settings tab, the active nav
button, and the card hover state.
Accessibility tests
The page a11y suite (
packages/e2e-tests/e2e/Accessibility/basicTests.cy.ts)now runs in both light and dark, driven by emulating the OS
prefers-color-schemeso the mode is deterministic (the site theme defaults to"system"). Added hovered-row checks for My Activities and Explore (via
cypress-real-events, so the CSS:hoverstate is actually evaluated) — thesecaught a real white-on-white hover bug that is fixed here.
Component tests (both modes). The
⚠️ This renames the required checks to
component-testsCI job is now atest-group × color-modematrix, so every component spec runs in light anddark (
--env colorMode=dark, via thetest:groupN:darkscripts).component-tests (groupN, light|dark)—branch-protection required checks must be updated to the new names. Tests
can also force a mode locally with
cy.mount(component, { colorMode }).New per-component specs were added across pages/widgets (About, Curate, Events,
GetInvolved, QuickLinks, SharedWithMe, SignIn, Trash, WelcomeBanner,
CompoundActivityEditor, ShareButton, ShareModal, …). To make modal/portaled and
charted content contrast-testable in isolation,
VisibilityPillandScoreSummaryChartwere extracted into their own files, and acheckContrastCypress command computes WCAG AA ratios directly where axe returns "incomplete"
(text inside a transformed/portaled Chakra
<Modal>).Out of scope:
apps/web(Astro marketing site) keeps its own light-only styling.🤖 Generated with Claude Code