feat(runner): DEV-2154 T0 — dual-mode theming via CSS custom properties - #90
Merged
demtario merged 6 commits intoJul 30, 2026
Merged
Conversation
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.
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.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
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.
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.

T0 of the DEV-2027 redesign. DEV-2154 · ADR-0022 · plan:
runner/docs/dev-2027-redesign-plan.mdBlocking — T1–T9 rebase on this. No user-visible payload of its own beyond the toggle.
Approach
theme.tsbecomes a two-mode token set.theme.color.*and the newtheme.shadow.*holdvar(--hot-…)references rather than literals, so the browser re-resolves them when the root attribute flips. That is what letsstyles.tsstay a frozen module-level object instead of becoming a hook: the ~90 existingtheme.color.*/s.*call sites keep working unchanged and become theme-reactive for free. The two palettes are typedRecord<ColorToken, string>against each other, so a missing dark value is a type error and they cannot drift.Neutrals are the Handsontable
horizonramp. 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
index.htmlsetsdata-hot-themeon<html>; React reads that attribute rather than re-deriving the mode, so the two cannot disagree.installThemeCss()runs as a module-scope side effect intheme.ts— whichstyles.tsandPreviewPane.tsximport directly — so the variables exist before React's first render regardless of import path.ThemeProvidermounts inmain.tsx.CodeEditorreads the mode from deep insideEditorShell, andApphas early returns (NotFound/Splash) above its main tree.localStorageand wins over the OS. With nothing stored, the app followsprefers-color-schemelive.color-schemeis 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.11:2535).logo-light.svg.logo.svgbakes in a#070604wordmark, invisible on the dark surface. Picked viauseLogoUrl(); inline-SVG components stay T1's remit./embed/:idacts on it is out of scope (ADR-0022).Dead tokens dropped: the
syn*family pluseditorGutter/editorText/editorSelectionhad no consumers.previewBgtrackssurfaceinstead of staying white, becausePreviewPane's boot and error overlays paint withsurfaceon 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.cssinside the preview iframe, so mixed light-chrome/dark-grid frames like65:21451are 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:6438showed 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 darkeditorBgto#19191c, sidebar ontosurfaceSunken, top bar ontosurfaceRaised. Fixed here rather than in review because nine subtasks inherit these tokens.Verification
pnpm --filter @handsontable/demo-editor-shell typecheckandpnpm --filter @handsontable/demo-authoring typecheck— both clean. Production build succeeds.grep -rEn '#[0-9a-fA-F]{3,8}|rgba?\(' packages/editor-shell/src apps/authoring/src --include='*.ts' --include='*.tsx'hitstheme.tsonly.48:6560/31:6438: top bar, sidebar, editor pane, cascader popover, My demos drawer, boot overlay.previewBgchange, so it needed to be seen rather than reasoned about.runner:verify, Angular, local API worker + Docker): container boots with the live install log rendering in dark and no white seam, reachesLivein ~36s, grid renders in the iframe, and a live edit throughCodeEditorpushes (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.mdunder 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:textMutedis#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.ShareDialog.tsxis dead code — nothing imports it; the live flow isShareLinks. 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.styles.cssedits — push returns 204, rule never applies within 30s; a.tsedit 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.1shows the sidebar's+/ ✎ / ✕ controls, so T3's "keep CRUD ineditmode" remains a reading of sticky72: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.tsnow defines paired light/dark palettes and emits them as--hot-*custom properties on:rootviadata-hot-theme, withtheme.color.*/theme.shadow.*exposed asvar()references so existing frozen style objects stay reactive. New elevation tokens (surfaceSunken,surfaceRaised, etc.), shadow tokens, and per-modecolor-schemereplace scattered hex/rgba across shell and authoring components.No flash on load: a synchronous inline script in
index.htmlsetsdata-hot-themefromlocalStorageorprefers-color-schemebefore React runs;ThemeProvider/useThemeread and sync that attribute (withuseLayoutEffecton toggle). The app root is wrapped inThemeProvider; 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.