import { expect, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ReactNode } from "react"; import type { Accent, ColorModePreference } from "../core/AlouetteConfig"; import { useCurrentMode } from "../core/ThemeContext"; import { tv } from "../core/tv"; import { AccentScope } from "../ui/containers/AccentScope"; import { ScopedTheme } from "../ui/containers/ScopedTheme"; import { Text } from "../ui/primitives/Text"; import { View } from "../ui/primitives/View"; import { Story, accentsWithoutNeutral } from "../ui/story-components/Story"; import { StoryGrid } from "../ui/story-components/StoryGrid"; const tokenSwatchVariants = tv({ base: "h-3", variants: { token: { screen: "bg-(--color-screen)", surface: "bg-(--color-surface)", highlight: "bg-(--color-highlight)", "highlight-accent": "bg-(--color-highlight-accent)", lowered: "bg-(--color-lowered)", emphasis: "bg-(--color-emphasis)", "screen-gradient-start": "bg-(--color-screen-gradient-start)", "screen-gradient-middle": "bg-(--color-screen-gradient-middle)", "screen-gradient-end": "bg-(--color-screen-gradient-end)", sharp: "bg-(--color-sharp)", accent: "bg-(--color-accent)", "on-accent": "bg-(--color-on-accent)", "on-accent-muted": "bg-(--color-on-accent-muted)", "on-emphasis": "bg-(--color-on-emphasis)", "on-tonal": "bg-(--color-on-tonal)", "border-sharp": "bg-(--color-border-sharp)", "border-muted": "bg-(--color-border-muted)", selection: "bg-(--color-selection)", "interactive-filled-pressable": "bg-(--color-interactive-filled-pressable)", "interactive-filled-hover": "bg-(--color-interactive-filled-hover)", "interactive-filled-active": "bg-(--color-interactive-filled-active)", "interactive-tonal-pressable": "bg-(--color-interactive-tonal-pressable)", "interactive-tonal-hover": "bg-(--color-interactive-tonal-hover)", "interactive-tonal-active": "bg-(--color-interactive-tonal-active)", "interactive-soft-hover": "bg-(--color-interactive-soft-hover)", "interactive-soft-active": "bg-(--color-interactive-soft-active)", "interactive-outlined-pressable": "bg-(--color-interactive-outlined-pressable)", "interactive-outlined-hover": "bg-(--color-interactive-outlined-hover)", "interactive-outlined-focus": "bg-(--color-interactive-outlined-focus)", "interactive-outlined-active": "bg-(--color-interactive-outlined-active)", "interactive-active": "bg-(--color-interactive-active)", "interactive-pressable": "bg-(--color-interactive-pressable)", "interactive-hover": "bg-(--color-interactive-hover)", translucent: "bg-(--color-translucent)", "disabled-sharp": "bg-(--color-disabled-sharp)", "disabled-muted": "bg-(--color-disabled-muted)", muted: "bg-(--color-muted)", "form-border-disabled": "bg-(--color-form-border-disabled)", "form-placeholder": "bg-(--color-form-placeholder)", "form-disabled-text": "bg-(--color-form-disabled-text)", "interactive-link-disabled": "bg-(--color-interactive-link-disabled)", "interactive-tonal-disabled": "bg-(--color-interactive-tonal-disabled)", "interactive-filled-disabled": "bg-(--color-interactive-filled-disabled)", "interactive-outlined-disabled": "bg-(--color-interactive-outlined-disabled)", "interactive-accent-outlined-border-disabled": "bg-(--color-interactive-accent-outlined-border-disabled)", }, }, }); interface TokenSwatchProps { token: | "accent" | "border-muted" | "border-sharp" | "disabled-muted" | "disabled-sharp" | "emphasis" | "form-border-disabled" | "form-disabled-text" | "form-placeholder" | "highlight-accent" | "highlight" | "interactive-accent-outlined-border-disabled" | "interactive-active" | "interactive-filled-active" | "interactive-filled-disabled" | "interactive-filled-hover" | "interactive-filled-pressable" | "interactive-hover" | "interactive-link-disabled" | "interactive-outlined-active" | "interactive-outlined-disabled" | "interactive-outlined-focus" | "interactive-outlined-hover" | "interactive-outlined-pressable" | "interactive-pressable" | "interactive-soft-active" | "interactive-soft-hover" | "interactive-tonal-active" | "interactive-tonal-disabled" | "interactive-tonal-hover" | "interactive-tonal-pressable" | "lowered" | "muted" | "on-accent-muted" | "on-accent" | "on-emphasis" | "on-tonal" | "screen-gradient-end" | "screen-gradient-middle" | "screen-gradient-start" | "screen" | "selection" | "sharp" | "surface" | "translucent"; } function TokenSwatch({ token }: TokenSwatchProps): ReactNode { return ( {token} ); } interface TokenGroupProps { group: string; children: NonNullable; } function TokenGroup({ group, children }: TokenGroupProps): ReactNode { return ( {group} {children} ); } interface AccentTokensProps { accent?: Accent; } function AccentTokens({ accent }: AccentTokensProps): ReactNode { return ( ); } interface ThemeTokensProps { themeMode: "dark" | "light"; } function ThemeTokens({ themeMode }: ThemeTokensProps): ReactNode { return ( {accentsWithoutNeutral.map((accent) => ( ))} ); } export default { title: "alouette/Config/Themes", parameters: { chromatic: { disableSnapshot: true }, }, } satisfies Meta; export const Themes: StoryObj = { render: () => ( ), }; interface ThemeProbeProps { label: string; } function ThemeProbe({ label }: ThemeProbeProps): ReactNode { return ( {label} ); } /** * On web a theme is a className and tokens resolve by CSS custom-property * inheritance, so the closest theme class must win whatever the order of the * generated blocks. `play` asserts it: a probe nested two accent scopes deep * resolves to the inner accent, not the outer one and not the file's last block. */ export const NestedThemes: StoryObj = { render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const accentOf = (label: string): string => getComputedStyle(canvas.getByLabelText(label)).backgroundColor; await expect(accentOf("danger then info")).toBe(accentOf("info")); await expect(accentOf("danger")).not.toBe(accentOf("info")); await expect(accentOf("danger")).not.toBe(accentOf("base")); }, }; interface ColorModeProbesProps { colorMode: ColorModePreference; } function ColorModeProbes({ colorMode }: ColorModeProbesProps): ReactNode { return ( {`${colorMode} reads ${useCurrentMode()}`} ); } /** * A `system` scope is resolved by `prefers-color-scheme` in the palette CSS, * so the prerendered HTML already wears the OS mode; its nested accent scope * stays a `system_*` theme rather than a mode resolved in JS. `useCurrentMode` * reads the same mode once hydrated. The expectations follow the runner's own * scheme. */ export const SystemColorMode: StoryObj = { render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const systemMode = globalThis.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; const accentOf = (label: string): string => getComputedStyle(canvas.getByLabelText(label)).backgroundColor; await expect(accentOf("light base")).not.toBe(accentOf("dark base")); await expect(accentOf("system base")).toBe(accentOf(`${systemMode} base`)); await expect(accentOf("system info")).toBe(accentOf(`${systemMode} info`)); await expect( canvas.getByLabelText("system info").closest("[data-theme]") ?.dataset.theme, ).toBe("system_info"); await expect( canvas.getByText(`system reads ${systemMode}`), ).toBeInTheDocument(); }, }; function ColorFormatProbes(): ReactNode { return ( ); } const accentFormatOf = (canvasElement: HTMLElement, label: string): string => { const { backgroundColor } = getComputedStyle( within(canvasElement).getByLabelText(label), ); return backgroundColor.slice(0, backgroundColor.indexOf("(")); }; /** * The `colorFormat` toolbar global drives which palette CSS the web preview * loads (`.storybook/preview.tsx` toggles the wide-gamut overlay stylesheet). * It defaults to sRGB, so tokens must resolve to hex-derived `rgb()`. */ export const ColorFormatSrgb: StoryObj = { render: () => , play: async ({ canvasElement }) => { await expect(accentFormatOf(canvasElement, "base accent")).toBe("rgb"); await expect(accentFormatOf(canvasElement, "info accent")).toBe("rgb"); }, }; /** Same probes with the toolbar on OKLCH: the overlay stylesheet is enabled. */ export const ColorFormatOklch: StoryObj = { globals: { colorFormat: "oklch" }, render: () => , play: async ({ canvasElement }) => { await expect(accentFormatOf(canvasElement, "base accent")).toBe("oklch"); await expect(accentFormatOf(canvasElement, "info accent")).toBe("oklch"); }, };