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");
},
};