From 868a10d94bee138333ca1bf4be2ded631cccd901 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 17:42:59 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- DESIGN.md | 2 +- .../src/components/Dashboard/settings.tsx | 5 ++-- .../src/components/Dashboard/ui/core/look.tsx | 28 +++++++++++-------- .../Dashboard/ui/core/theme.check.ts | 2 +- .../src/components/Dashboard/ui/core/theme.ts | 2 +- .../Dashboard/ui/fluksio/Controls.tsx | 10 +++++-- .../Dashboard/ui/glass/Controls.tsx | 10 +++++-- .../components/Dashboard/ui/glass/glass.css | 2 +- .../Dashboard/ui/material/Controls.tsx | 10 +++++-- .../Dashboard/ui/material/Surfaces.tsx | 10 ++++++- frontend/src/components/Flow/BrainView.tsx | 5 ++-- frontend/src/components/Flow/FlowEditor.tsx | 5 ++-- frontend/src/components/Flow/flow.css | 8 +++--- frontend/src/index.css | 26 +++++++++++------ 14 files changed, 78 insertions(+), 47 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 19f4551..706c2c5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/frontend/src/components/Dashboard/settings.tsx b/frontend/src/components/Dashboard/settings.tsx index 18cd319..0abd77f 100644 --- a/frontend/src/components/Dashboard/settings.tsx +++ b/frontend/src/components/Dashboard/settings.tsx @@ -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 diff --git a/frontend/src/components/Dashboard/ui/core/look.tsx b/frontend/src/components/Dashboard/ui/core/look.tsx index eb009ad..cd6a55f 100644 --- a/frontend/src/components/Dashboard/ui/core/look.tsx +++ b/frontend/src/components/Dashboard/ui/core/look.tsx @@ -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({ 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 {children} } diff --git a/frontend/src/components/Dashboard/ui/core/theme.check.ts b/frontend/src/components/Dashboard/ui/core/theme.check.ts index 8c9a420..79739b8 100644 --- a/frontend/src/components/Dashboard/ui/core/theme.check.ts +++ b/frontend/src/components/Dashboard/ui/core/theme.check.ts @@ -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", ) diff --git a/frontend/src/components/Dashboard/ui/core/theme.ts b/frontend/src/components/Dashboard/ui/core/theme.ts index 622397e..9c705c2 100644 --- a/frontend/src/components/Dashboard/ui/core/theme.ts +++ b/frontend/src/components/Dashboard/ui/core/theme.ts @@ -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. * diff --git a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx index efad0ef..d89901b 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx @@ -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 ( {options.map((option) => ( diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index 0103ed1..6661c59 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -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 ( {options.map((option) => ( diff --git a/frontend/src/components/Dashboard/ui/glass/glass.css b/frontend/src/components/Dashboard/ui/glass/glass.css index 3b2f57a..d4098e4 100644 --- a/frontend/src/components/Dashboard/ui/glass/glass.css +++ b/frontend/src/components/Dashboard/ui/glass/glass.css @@ -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); diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index f5fdc58..7861c4a 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -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 ( {options.map((option) => ( diff --git a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx index 1b39084..6d2f342 100644 --- a/frontend/src/components/Dashboard/ui/material/Surfaces.tsx +++ b/frontend/src/components/Dashboard/ui/material/Surfaces.tsx @@ -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 ( @@ -49,7 +52,12 @@ function Issue({ issue }: { issue: string }) { data-testid={TESTID.issue} /> - {issue} + + {issue} + ) } diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx index 8a7b7db..82ab43d 100644 --- a/frontend/src/components/Flow/BrainView.tsx +++ b/frontend/src/components/Flow/BrainView.tsx @@ -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} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 8ae0f69..239f751 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -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} diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index aa26557..24d478d 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -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 { diff --git a/frontend/src/index.css b/frontend/src/index.css index 4d5b59a..732804f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 ``, 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;