Namespace the subtree theme scopes as .theme-light/.theme-dark: dark: follows the nearest scope, portalled dashboard menus and tooltips restate it
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user