Skip to content

feat(runner): DEV-2154 T0 — dual-mode theming via CSS custom properties - #90

Merged
demtario merged 6 commits into
feat/DEV-2027-redesignfrom
feat/DEV-2154-theming-foundation
Jul 30, 2026
Merged

feat(runner): DEV-2154 T0 — dual-mode theming via CSS custom properties#90
demtario merged 6 commits into
feat/DEV-2027-redesignfrom
feat/DEV-2154-theming-foundation

Conversation

@demtario

@demtario demtario commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

T0 of the DEV-2027 redesign. DEV-2154 · ADR-0022 · plan: runner/docs/dev-2027-redesign-plan.md

Blocking — T1–T9 rebase on this. No user-visible payload of its own beyond the toggle.

Approach

theme.ts becomes a two-mode token set. theme.color.* and the new theme.shadow.* hold var(--hot-…) references rather than literals, so the browser re-resolves them when the root attribute flips. That is what lets styles.ts stay a frozen module-level object instead of becoming a hook: the ~90 existing theme.color.* / s.* call sites keep working unchanged and become theme-reactive for free. The two palettes are typed Record<ColorToken, string> against each other, so a missing dark value is a type error and they cannot drift.

Neutrals are the Handsontable horizon ramp. Assignment was sampled pixel-by-pixel out of the frames rather than inferred from the variable list — see the correction commit below.

What's in it

  • No flash on load. A pre-paint inline script in index.html sets data-hot-theme on <html>; React reads that attribute rather than re-deriving the mode, so the two cannot disagree. installThemeCss() runs as a module-scope side effect in theme.ts — which styles.ts and PreviewPane.tsx import directly — so the variables exist before React's first render regardless of import path.
  • ThemeProvider mounts in main.tsx. CodeEditor reads the mode from deep inside EditorShell, and App has early returns (NotFound / Splash) above its main tree.
  • Explicit choice persists to localStorage and wins over the OS. With nothing stored, the app follows prefers-color-scheme live.
  • color-scheme is emitted per mode so native scrollbars and <select> popups follow the shell. No token can reach those — this was not in the plan and is worth keeping.
  • CodeMirror moves from one-dark to GitHub Light/Dark, matching the docs site (Figma sticky 11:2535).
  • logo-light.svg. logo.svg bakes in a #070604 wordmark, invisible on the dark surface. Picked via useLogoUrl(); inline-SVG components stay T1's remit.
  • Embed URL carries a preferred-theme hint. How /embed/:id acts on it is out of scope (ADR-0022).
  • Every remaining colour literal is now a token, including form-control backgrounds the UA would otherwise paint light inside a dark shell.

Dead tokens dropped: the syn* family plus editorGutter / editorText / editorSelection had no consumers. previewBg tracks surface instead of staying white, because PreviewPane's boot and error overlays paint with surface on top of it — white behind them would make the overlay lift to reveal a white pane in dark mode.

The running example is deliberately not re-themed: it imports its own ht-theme-main.min.css inside the preview iframe, so mixed light-chrome/dark-grid frames like 65:21451 are that, not a second control.

Second commit: the ramp was wrong

I first inferred the dark surface assignment from the Figma variable list. Sampling the actual pixels out of 31:6438 showed otherwise: sidebar #000000, row-2 bar and preview surround #070604, editor pane and status bars #19191c, top bar #222222. The four token values were right; which surface gets which step was not, and the set was missing its deepest step entirely. Light collapses onto #ffffff / #f7f7f9, which is why only the dark frame separates them.

So: added surfaceSunken, moved dark editorBg to #19191c, sidebar onto surfaceSunken, top bar onto surfaceRaised. Fixed here rather than in review because nine subtasks inherit these tokens.

Verification

  • pnpm --filter @handsontable/demo-editor-shell typecheck and pnpm --filter @handsontable/demo-authoring typecheck — both clean. Production build succeeds.
  • Grep gate: grep -rEn '#[0-9a-fA-F]{3,8}|rgba?\(' packages/editor-shell/src apps/authoring/src --include='*.ts' --include='*.tsx' hits theme.ts only.
  • Theme precedence, all five cases: OS-dark + no choice → dark; OS-light + no choice → light; explicit choice persists across reload; explicit choice survives a live OS flip; no stored choice follows a live OS flip.
  • Custom properties resolve to the dark palette at React's first DOM insertion, checked with a MutationObserver — no unstyled frame, not just "looks fine".
  • Both modes eyeballed against 48:6560 / 31:6438: top bar, sidebar, editor pane, cascader popover, My demos drawer, boot overlay.
  • The boot overlay was rendered in dark, Tier-1 and Tier-2 — that is the surface that motivated the previewBg change, so it needed to be seen rather than reasoned about.
  • Tier-2 end-to-end (runner:verify, Angular, local API worker + Docker): container boots with the live install log rendering in dark and no white seam, reaches Live in ~36s, grid renders in the iframe, and a live edit through CodeEditor pushes (POST /api/session/:id/file → 204) and hot-reloads into the preview in ~2s. Session destroyed afterwards; no repo files touched. The styling refactor did not disturb the iframe plumbing.

Open items — logged, not resolved here

All three are recorded in runner/docs/dev-2027-redesign-plan.md under Open items raised during implementation, a running log later subtasks append to. Per the plan, these get decided in one pass at the end of the redesign rather than gating this PR:

  1. Dark textMuted is #8f8f94, not the Figma #727272 — that variable is ~3.9:1 on #070604, under AA. Possibly intended for icons rather than muted body text, in which case the fix is two tokens rather than one lightened value. Design call.
  2. ShareDialog.tsx is dead code — nothing imports it; the live flow is ShareLinks. Both DEV-2154 and ADR-0022 name it as the surface carrying the embed URL, so the ADR's file citation is wrong. Tokenized and given the hint anyway so it stays consistent if revived.
  3. The Angular container ignores global styles.css edits — push returns 204, rule never applies within 30s; a .ts edit applies in ~2s. Surfaced by this PR's Tier-2 run, not caused by it, not investigated. Likely its own ticket outside DEV-2027.

This PR also records the measured ramp in the plan's T0 section, since T2–T5 assign against it.

Also worth one line from design at review: no frame in 18.1 shows the sidebar's + / ✎ / ✕ controls, so T3's "keep CRUD in edit mode" remains a reading of sticky 72:14532, not something a frame demonstrates.

🤖 Generated with Claude Code


Note

Medium Risk
Wide visual refactor across shell and authoring with new theme state and localStorage, but no auth, API, or data-path changes; incorrect token wiring would affect all surfaces until fixed.

Overview
T0 theming foundation for the DEV-2027 redesign: the authoring app and editor shell flip between light and dark as one system instead of a fixed light palette.

theme.ts now defines paired light/dark palettes and emits them as --hot-* custom properties on :root via data-hot-theme, with theme.color.* / theme.shadow.* exposed as var() references so existing frozen style objects stay reactive. New elevation tokens (surfaceSunken, surfaceRaised, etc.), shadow tokens, and per-mode color-scheme replace scattered hex/rgba across shell and authoring components.

No flash on load: a synchronous inline script in index.html sets data-hot-theme from localStorage or prefers-color-scheme before React runs; ThemeProvider / useTheme read and sync that attribute (with useLayoutEffect on toggle). The app root is wrapped in ThemeProvider; a ThemeToggle sits in the top bar.

Editor & branding: CodeMirror switches from One Dark to GitHub Light/Dark; useLogoUrl() picks a light-ink wordmark SVG in dark mode. Embed/share URLs append a ?theme= hint (handling is out of scope).

The redesign plan documents the measured surface ramp and logs open follow-ups (muted text contrast, dead ShareDialog, Angular CSS preview quirk).

Reviewed by Cursor Bugbot for commit b6fa550. Bugbot is set up for automated code reviews on this repo. Configure here.

demtario added 2 commits July 30, 2026 12:14
Rewrites the shell's styling layer so it can do dark mode. This is T0 of the
DEV-2027 redesign and blocks T1-T9 (ADR-0022).

theme.ts becomes a two-mode token set. `theme.color.*` and the new
`theme.shadow.*` now hold `var(--hot-…)` references rather than literals, so
the browser re-resolves them when the root attribute flips. That lets styles.ts
stay a frozen module-level object instead of becoming a hook — the ~90 existing
call sites keep working and become theme-reactive without being touched.

Neutrals come from the Handsontable `horizon` ramp read off the Figma variables
on 48:6560 (light) and 31:6438 (dark).

- Pre-paint inline script in index.html sets `data-hot-theme` on <html>, so a
  dark reload never flashes light. React reads that attribute instead of
  re-deriving the mode, so the two cannot disagree.
- installThemeCss() runs as a module-scope side effect in theme.ts, which
  styles.ts and PreviewPane.tsx import directly — the variables therefore exist
  before React's first render regardless of import path.
- ThemeProvider mounts in main.tsx: CodeEditor reads the mode from inside
  EditorShell, and App has early returns above its main tree.
- Explicit choice persists to localStorage and wins over the OS; with no stored
  choice the app follows prefers-color-scheme live.
- `color-scheme` is emitted per mode so native scrollbars and <select> popups
  follow the shell.
- CodeMirror moves from one-dark to GitHub Light/Dark, matching the docs site
  (Figma sticky 11:2535).
- logo.svg bakes in a #070604 wordmark, invisible on the dark surface, so a
  white-ink logo-light.svg ships alongside it behind useLogoUrl().
- The embed URL now carries a preferred-theme hint. How /embed/:id acts on it is
  out of scope (ADR-0022).
- Every remaining colour literal is now a token, including the form-control
  backgrounds that the UA would otherwise paint light in a dark shell.

Dead tokens dropped: the syn* family plus editorGutter/editorText/
editorSelection had no consumers. previewBg tracks surface rather than staying
white, because PreviewPane's boot and error overlays paint with surface on top
of it.

The running example is deliberately not re-themed: it imports its own
ht-theme-main.min.css inside the preview iframe, so mixed light-chrome/dark-grid
frames are that, not a second control.

Dark textMuted is lightened from the Figma #727272 to #8f8f94, which lands under
AA on #070604 — flag at design review.
Sampled the actual surface pixels out of 31:6438 (dark) and 48:6560 (light)
rather than inferring assignment from the horizon variable list. The four token
values were right; which surface gets which step was not, and the design uses a
step the ramp was missing.

Dark, measured: sidebar #000000 · row-2 bar and preview surround #070604 ·
editor pane and status bars #19191c · top bar #222222. Light collapses onto
#ffffff / #f7f7f9, which is why only the dark frame separates the steps.

- Adds `surfaceSunken` (#f7f7f9 / #000000) for the recessed sidebar — the deepest
  step, absent from the original set.
- Dark `editorBg` #070604 -> #19191c: the editor pane is a content pane, not the
  app ground.
- Sidebar moves from `surfaceMuted` to `surfaceSunken`, and the top bar from
  `surfaceMuted` to `surfaceRaised` (it is the most-raised chrome, #222222).

Getting this right matters here rather than in review: T1-T9 all rebase on these
tokens, so a wrong assignment propagates into nine subtasks.

Also drops `palette()`, which had no consumer once CodeEditor took the GitHub
themes directly — the same standard the syn* removal was held to — and stops
ShareDialog emitting a second `?` if the API ever returns an embed path that
already carries a query.
@demtario demtario self-assigned this Jul 30, 2026
Comment thread runner/apps/authoring/index.html Outdated
Comment thread runner/packages/editor-shell/src/useTheme.ts
demtario added 2 commits July 30, 2026 12:37
Adds an "Open items raised during implementation" section as a running log:
subtasks append to it rather than resolving inline, so the calls get made in one
pass at the end of the redesign and nothing blocks a subtask from landing.

Three items from T0:
- Dark `textMuted` ships #8f8f94 against the Figma #727272, which is under AA on
  #070604. The Figma value may be intended for icons rather than muted body
  text, in which case the fix is two tokens rather than one lightened value.
- ShareDialog.tsx is dead code, and both DEV-2154 and ADR-0022 name it as the
  surface that hands out the embed URL — so the ADR's file citation is wrong,
  not merely incomplete.
- The Angular container never applies global styles.css edits even though the
  push returns 204, while a component edit applies in ~2s. Not caused by the
  theming work and not investigated; records what was and wasn't established so
  it can be picked up cold.

Also records the ramp T0 measured out of the frames, since T2-T5 assign against
it and should not re-derive it. Sampling mattered: the assignment inferred from
the variable list was wrong, and the set was missing its deepest step.
…available

Both from Bugbot on #90; both reproduce.

Pre-paint script: a single try/catch wrapped the storage read *and* the
prefers-color-scheme fallback, so when storage throws — Safari private mode,
blocked cookies — the catch set light unconditionally and a dark-OS user got a
light shell. Recovery only came if the OS preference later changed, because
ThemeProvider treats the attribute as the source of truth and never re-derives
it. Now only the storage read is guarded, so losing storage no longer costs the
OS preference too.

OS listener: it gated on `storedChoice() === null`, which cannot distinguish
"user has not chosen" from "user chose but the write was rejected". After a
failed persist a live OS flip silently overrode the user's toggle — contradicting
the very comment in setMode calling the choice usable for the session. Intent is
now held in a ref, seeded from storage so an earlier session's choice still
counts, and set on every setMode regardless of whether persisting worked.

Verified with localStorage.getItem/setItem actually throwing: dark OS with no
storage comes up dark, light OS comes up light, and a toggle survives OS flips
in both directions. Re-ran the precedence matrix in a clean context to confirm
the ref did not break OS-following: no stored choice still tracks live flips both
ways, an explicit choice still wins, and it still survives a reload.
Comment thread runner/packages/editor-shell/src/useTheme.ts
Bugbot on #90. `data-hot-theme` was written in a passive `useEffect`. The
attribute is what the CSS variables key off, while the same `mode` also drives
committed DOM — the logo asset and CodeMirror's theme. Passive effects carry no
guarantee of running before the browser paints, so on toggle the two could
disagree for a frame: a white wordmark on a still-light bar. `useLayoutEffect`
runs synchronously after commit and before paint, which closes it by contract
rather than by luck.

The OS-listener effect stays passive — it is a subscription and has no bearing on
paint.

Note on evidence: sampling every animation frame across a toggle showed no
mismatch either before or after this change. That is not proof the defect was
absent, because rAF callbacks fire before paint — if React commits the DOM and
flushes the passive effect within one task, the probe sees both applied together
whatever the paint ordering was. The change is made because the ordering was
unguaranteed, not because a bad frame was observed. Re-ran the precedence matrix
after it: OS-following, explicit choice winning, and persistence across reload all
still hold, with no useLayoutEffect/SSR console warnings.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 47580bd. Configure here.

Comment thread runner/packages/editor-shell/src/FileTree.tsx
Bugbot on #90. `editInput` — shared by the add-file and rename fields — never set
background or color, so it fell back to UA chrome while every other form control
in this pass was tokenized. Audited the other five (shell `select`, App
`selectStyle`, ShareLinks/ShareDialog `input`, cascader `search`): all already set
both, so this was the only miss.

Uses `surfaceRaised`, not `surface`. The field floats over the sidebar, which is
`surfaceSunken` #000000 in dark — `surface` #070604 would be all but invisible
against it, whereas #222222 reads as a lifted, editable field. Light mode gets
#ffffff on the #f7f7f9 sidebar, which is the same relationship.

Bugbot's stated symptom was wrong: the fields were not keeping *light* UA chrome.
Measured before the fix, the input resolved to rgb(59,59,59) on white text,
because `color-scheme: dark` was already darkening UA controls. The real defect is
that #3b3b3b appears nowhere in the ramp, so an active input was the one surface
not speaking the token system.

Worth noting for later subtasks: the grep gate in the acceptance criteria cannot
catch this class of bug. It looks for wrong colour literals, and this was a pair
of absent declarations. Auditing form controls for background+color is a separate
check.
@demtario
demtario merged commit 94954dd into feat/DEV-2027-redesign Jul 30, 2026
2 checks passed
@demtario
demtario deleted the feat/DEV-2154-theming-foundation branch July 30, 2026 11:05
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.

1 participant