Skip to content

feat: site-wide dark mode (DoenetML 0.7.21) with accessibility tests#3010

Open
dqnykamp wants to merge 37 commits into
Doenet:mainfrom
dqnykamp:upgrade-doenetml-dark-mode
Open

feat: site-wide dark mode (DoenetML 0.7.21) with accessibility tests#3010
dqnykamp wants to merge 37 commits into
Doenet:mainfrom
dqnykamp:upgrade-doenetml-dark-mode

Conversation

@dqnykamp

@dqnykamp dqnykamp commented Jul 18, 2026

Copy link
Copy Markdown
Member

Adds a site-wide dark mode to apps/app, driven by a tri-state theme
setting (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: semanticTokens with { default, _dark } for the core palette
    (background/surface/interact/border/text/accent, surfaceMuted, textMuted,
    iconAccent, and theme-sensitive doenet.*), so token-based usages flip
    automatically. Dark-aware Alert and Button variants.
  • index.html: pre-paint FOUC script (resolves system, sets data-theme +
    colorScheme, pre-seeds Chakra's key) so there's no flash; keeps the
    [data-theme="dark"] CSS-var block active.
  • New utils/theme.ts: useThemeSetting (tri-state state, Chakra sync, live
    prefers-color-scheme listener, localStorage + DB persistence), a
    ThemeSettingProvider React context (survives the editor's nested outlet),
    and effectiveDarkMode() which forces light for docs pinned below DoenetML
    0.7.21
    (their CDN engine predates dark-mode CSS).

Control + persistence

  • System/Light/Dark radio in the avatar menu; threaded through the navbar.
  • New users.theme column (+ migration), setTheme query/route/zod schema,
    theme added to getMyUserInfo; localStorage for anonymous + FOUC.

Viewer wiring

  • darkMode={effectiveDarkMode(setting, version)} on the singleDoc/editor
    render sites; raw setting on compound-activity sites. /embed RawViewer
    stays system (it's an external-iframe endpoint outside the theme tree).

Full color sweep across pages/widgets (hardcoded white/black/gray.N
and 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-scheme so 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 :hover state is actually evaluated) — these
caught a real white-on-white hover bug that is fixed here.

Component tests (both modes). The component-tests CI job is now a
test-group × color-mode matrix, so every component spec runs in light and
dark (--env colorMode=dark, via the test:groupN:dark scripts).
⚠️ This renames the required checks to 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, VisibilityPill and
ScoreSummaryChart were extracted into their own files, and a checkContrast
Cypress 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

dqnykamp and others added 29 commits July 17, 2026 22:21
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
…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
@dqnykamp
dqnykamp force-pushed the upgrade-doenetml-dark-mode branch from 2e776ad to 35fb95c Compare July 18, 2026 18:30
dqnykamp and others added 3 commits July 18, 2026 14:50
…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
Comment thread apps/app/src/utils/theme.ts Outdated
Comment thread apps/app/index.html
<!doctype html>
<html lang="en">
<head>
<!--

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems questionable. Is this how you're supposed to do it with Chakra?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cqnykamp

Copy link
Copy Markdown
Contributor

Also, usually the theme selector is 3 inline options. Clicking one shouldn't close the account popup.

@cqnykamp

Copy link
Copy Markdown
Contributor

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.

@cqnykamp

Copy link
Copy Markdown
Contributor

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").

dqnykamp and others added 3 commits July 19, 2026 18:17
…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
@dqnykamp

Copy link
Copy Markdown
Member Author

Also, usually the theme selector is 3 inline options. Clicking one shouldn't close the account popup.

Fixed

@dqnykamp

Copy link
Copy Markdown
Member Author

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").

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.

@dqnykamp

Copy link
Copy Markdown
Member Author

Here's a proposal that Claude made for softening the dark mode.

@cqnykamp

Copy link
Copy Markdown
Contributor

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants