Namespace the subtree theme scopes as .theme-light/.theme-dark: dark: follows the nearest scope, portalled dashboard menus and tooltips restate it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 17:42:59 +02:00
co-authored by Claude Opus 5.5
parent 58b92da8b0
commit 868a10d94b
14 changed files with 78 additions and 47 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ any UI work.
## Sync rule (read before touching tokens)
The `@theme inline`, `:root`, and `.dark` blocks in `app/frontend/src/index.css`
The `@theme inline`, `:root`, and `.theme-dark` blocks in `app/frontend/src/index.css`
and `index/src/index.css` **must be kept byte-identical**, and
`frontend/src/lib/motion.ts` is duplicated verbatim. Any palette / radius /
elevation / motion-token change is made in *both* repos in the same change.
@@ -139,8 +139,9 @@ export function useDashboardBackground(
* The class that themes a dashboard's own surface, or `""` to follow the app.
*
* A class rather than the root, because inside the app shell this must not
* flip the chrome around it. `.light` and `.dark` both redefine the tokens on
* whatever carries them (`index.css`), so either direction works on a subtree.
* flip the chrome around it. `.theme-light` and `.theme-dark` both redefine the
* tokens on whatever carries them (`index.css`), and `dark:` follows the
* nearest of them, so either direction works on a subtree.
*
* A palette decides this for itself: its first colour *is* the ground, so
* whether the surface is light or dark is a fact about the palette rather than
@@ -6,9 +6,10 @@
* below reads them from there: the tokens by inheritance, the look and the
* touch flag through this context.
*
* The style is carried in the context as well as on the element, because a
* menu is portalled to `body` and lands outside the canvas. A surface that
* floats out of the tree re-states the tokens rather than borrowing the app's.
* The style and the theme class are carried in the context as well as on the
* element, because a menu is portalled to `body` and lands outside the canvas.
* A surface that floats out of the tree re-states them rather than borrowing
* the app's.
*/
import { createContext, useContext, useMemo } from "react"
@@ -28,6 +29,8 @@ type Canvas = {
touch: boolean
/** The palette's tokens, or `{}` when the dashboard names none. */
style: React.CSSProperties
/** The theme scope class, `theme-light` or `theme-dark`. */
theme: string
}
/** A dashboard that names no palette, as one object rather than one per render. */
@@ -37,6 +40,7 @@ const LookContext = createContext<Canvas>({
look: "material",
touch: false,
style: {},
theme: "",
})
/** The look everything under here is drawn in. */
@@ -46,10 +50,10 @@ export const useLook = () => useContext(LookContext)
* What the canvas root carries: the theme class, the palette, and the two
* attributes the looks' stylesheets key off.
*
* The class is always resolved to `light` or `dark`, rather than left empty
* for a dashboard that follows the device: the sets state their own colours
* per theme, and a chart canvas has to be told which one it is drawing in.
* Restating the app's own resolved theme changes nothing when they agree.
* The class is always resolved to `theme-light` or `theme-dark`, rather than
* left empty for a dashboard that follows the device: the sets state their own
* colours per theme, and a chart canvas has to be told which one it is drawing
* in. Restating the app's own resolved theme changes nothing when they agree.
*
* `touch` is passed in rather than read off the document, because it belongs to
* the screen rather than to the dashboard: the same document may hang on a
@@ -73,7 +77,7 @@ export function useCanvasRoot(
[roles],
)
return {
className: stated || (resolvedTheme === "dark" ? "dark" : "light"),
className: `theme-${stated || resolvedTheme}`,
style,
"data-look": look,
"data-touch": touch ? "" : undefined,
@@ -101,13 +105,13 @@ export function LookProvider({
const root = useCanvasRoot(dashboard, touch)
const look = root["data-look"]
const touched = root["data-touch"] !== undefined
const { style } = root
const { style, className: theme } = root
// A bound setting wakes this component on every message its flow sends, and
// what the widgets below actually react to is the value — so it changes only
// when one of the three things it carries does.
// when one of the four things it carries does.
const value = useMemo(
() => ({ look, touch: touched, style }),
[look, touched, style],
() => ({ look, touch: touched, style, theme }),
[look, touched, style, theme],
)
return <LookContext.Provider value={value}>{children}</LookContext.Provider>
}
@@ -121,7 +121,7 @@ assert.deepEqual(
)
assert.deepEqual(
rolesOf(["#0a0a0a"])?.series.slice(1),
slots(block(".dark {")),
slots(block(".theme-dark {")),
"a dark dashboard continues with the dark --chart-2…8",
)
@@ -10,7 +10,7 @@
*
* The result is written onto the canvas as the plain token variables the app
* already themes with (`--background`, `--card`, `--primary`, …), which is the
* same mechanism `.light` and `.dark` use on a subtree. Nothing here touches
* same mechanism `.theme-light` and `.theme-dark` use on a subtree. Nothing here touches
* `index.css`: that file's token blocks are byte-identical with the website's
* and `make design-check` holds them there.
*
@@ -154,8 +154,9 @@ export function Select({
onChange,
}: SelectProps) {
// The menu is portalled to `body`, which is outside the canvas — so it
// re-states the dashboard's own colours rather than borrowing the app's.
const { style } = useLook()
// re-states the dashboard's own theme and colours rather than borrowing the
// app's.
const { style, theme } = useLook()
return (
<SelectPrimitive.Root
value={value}
@@ -178,7 +179,10 @@ export function Select({
position="popper"
sideOffset={4}
style={style}
className="fx-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
className={cn(
theme,
"fx-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0",
)}
>
<SelectPrimitive.Viewport>
{options.map((option) => (
@@ -172,8 +172,9 @@ export function Select({
onChange,
}: SelectProps) {
// The menu is portalled to `body`, which is outside the canvas — so it
// re-states the dashboard's own colours rather than borrowing the app's.
const { style } = useLook()
// re-states the dashboard's own theme and colours rather than borrowing the
// app's.
const { style, theme } = useLook()
return (
<SelectPrimitive.Root
value={value}
@@ -200,7 +201,10 @@ export function Select({
position="popper"
sideOffset={4}
style={style}
className="gl-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
className={cn(
theme,
"gl-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
)}
>
<SelectPrimitive.Viewport>
{options.map((option) => (
@@ -42,7 +42,7 @@
--dui-on-handle: rgb(0 0 0 / 0.75);
}
[data-look="glass"].light {
[data-look="glass"].theme-light {
--gl-bg: rgb(255 255 255 / 0.5);
--gl-bg-strong: rgb(255 255 255 / 0.7);
--gl-border: rgb(255 255 255 / 0.75);
@@ -199,8 +199,9 @@ export function Select({
onChange,
}: SelectProps) {
// The menu is portalled to `body`, which is outside the canvas — so it
// re-states the dashboard's own colours rather than borrowing the app's.
const { style } = useLook()
// re-states the dashboard's own theme and colours rather than borrowing the
// app's.
const { style, theme } = useLook()
return (
<SelectPrimitive.Root
value={value}
@@ -226,7 +227,10 @@ export function Select({
position="popper"
sideOffset={4}
style={style}
className="m3-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0"
className={cn(
theme,
"m3-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:fade-in-0",
)}
>
<SelectPrimitive.Viewport>
{options.map((option) => (
@@ -23,6 +23,7 @@ import type {
RailProps,
} from "../core/contract"
import { railGeometry, TESTID } from "../core/contract"
import { useLook } from "../core/look"
import { LOOK } from "../core/motion"
/** Material's ground is the surface colour itself; only an image is drawn. */
@@ -39,6 +40,8 @@ export function Backdrop({ image }: BackdropProps) {
}
function Issue({ issue }: { issue: string }) {
// Portalled to `body`, so it re-states the dashboard's theme and colours.
const { style, theme } = useLook()
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -49,7 +52,12 @@ function Issue({ issue }: { issue: string }) {
data-testid={TESTID.issue}
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
<TooltipContent
className={cn(theme, "max-w-xs break-words")}
style={style}
>
{issue}
</TooltipContent>
</Tooltip>
)
}
+2 -3
View File
@@ -219,9 +219,8 @@ function BrainCanvas() {
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
// React Flow stamps this on the wrapper as a class, and the app's own
// token scopes are named `.light` / `.dark` (index.css) — so leaving it
// unset re-themes the whole canvas to light inside a dark shell.
// React Flow's own defaults, for whatever flow.css does not route
// through the tokens, follow the theme too.
colorMode={resolvedTheme}
fitView
fitViewOptions={FIT}
+2 -3
View File
@@ -1284,9 +1284,8 @@ function FlowEditorInner({
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
// React Flow stamps this on the wrapper as a class, and the app's own
// token scopes are named `.light` / `.dark` (index.css) — so leaving it
// unset re-themes the whole canvas to light inside a dark shell.
// React Flow's own defaults, for whatever flow.css does not route
// through the tokens, follow the theme too.
colorMode={resolvedTheme}
fitView
fitViewOptions={FIT_VIEW}
+4 -4
View File
@@ -2,10 +2,10 @@
* React Flow, routed through the design tokens.
*
* Scoped to `.react-flow` rather than added to index.css, so the canvas needs no
* tokens of its own. The `colorMode` prop is passed all the same: React Flow
* stamps it on the wrapper as a class, and `.light` / `.dark` are the app's own
* token scopes — an unset one defaults to `light` and re-themes the whole canvas
* inside a dark shell. See the root DESIGN-GUIDELINES.md → Shells.
* tokens of its own. The `colorMode` prop is passed all the same, so React
* Flow's own defaults for anything not routed here follow the theme. The class
* it stamps on the wrapper cannot re-theme the canvas: the app's token scopes
* are `:root.dark` and `.theme-light` / `.theme-dark` (index.css).
*/
.react-flow {
+17 -9
View File
@@ -2,14 +2,19 @@
@import "tw-animate-css";
@import "./vendor.css";
@custom-variant dark (&:is(.dark *));
/* `dark:` follows the nearest theme scope: inside the dark document or a
`.theme-dark` subtree, unless a `.theme-light` subtree sits in between. */
@custom-variant dark (&:is(:where(:root.dark, .theme-dark) *):not(
:where(:root.dark, .theme-dark) .theme-light *
));
/*
* Self-hosted font, duplicated verbatim across the fluksio and website repos (a
* sync point like `lib/motion.ts`; see the root DESIGN-GUIDELINES.md,
* Typography). Kept OUT of the byte-identical `@theme inline` / `:root` /
* `.dark` blocks. Inter is used for both body (set on `body` in `@layer base`)
* and the display face applied via the `.font-display` class below.
* `.theme-dark` blocks. Inter is used for both body (set on `body` in
* `@layer base`) and the display face applied via the `.font-display` class
* below.
*/
@font-face {
font-family: "Inter";
@@ -106,11 +111,13 @@
* `--primary`, the rest are hues that clear 3:1 against `--card` and stay
* apart under colour blindness. See DESIGN-GUIDELINES.md → Colour tokens.
*/
/* `.light` is the symmetric half of `.dark` below: both are plain classes, so
either themes a subtree as well as the whole document — which is what lets a
dashboard forced to one theme sit inside a shell on the other. `:root` is
listed second so the parity grep keeps its anchor line. */
.light,
/* `.theme-light` and `.theme-dark` theme a subtree, which is what lets a
dashboard forced to one theme sit inside a shell on the other. The document
is themed by the bare `.dark` the theme provider puts on `<html>`, matched
only there as `:root.dark`, so a library that stamps `light` or `dark` on its
own wrapper cannot re-theme what is inside it. `:root` is listed second so
the parity grep keeps its anchor line. */
.theme-light,
:root {
--background: #ffffff;
--foreground: #333232;
@@ -152,7 +159,8 @@
--chart-8: #b32474;
}
.dark {
:root.dark,
.theme-dark {
--background: #0a0a0a;
--foreground: #f5f5f5;
--card: #1a1a1a;