@charcuterie/ui 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts +35 -0
  2. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.d.ts.map +1 -0
  3. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js +33 -0
  4. package/dist/ColorSchemeSwitcher/ColorSchemeSwitcher.js.map +1 -0
  5. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts +54 -0
  6. package/dist/ColorSchemeToggle/ColorSchemeToggle.d.ts.map +1 -0
  7. package/dist/ColorSchemeToggle/ColorSchemeToggle.js +30 -0
  8. package/dist/ColorSchemeToggle/ColorSchemeToggle.js.map +1 -0
  9. package/dist/Lightbox/Lightbox.d.ts +57 -0
  10. package/dist/Lightbox/Lightbox.d.ts.map +1 -0
  11. package/dist/Lightbox/Lightbox.js +50 -0
  12. package/dist/Lightbox/Lightbox.js.map +1 -0
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/package.json +3 -3
  18. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.mdx +70 -0
  19. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +118 -0
  20. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.test.tsx +53 -0
  21. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.tsx +90 -0
  22. package/src/ColorSchemeToggle/ColorSchemeToggle.mdx +48 -0
  23. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +161 -0
  24. package/src/ColorSchemeToggle/ColorSchemeToggle.test.tsx +80 -0
  25. package/src/ColorSchemeToggle/ColorSchemeToggle.tsx +107 -0
  26. package/src/Lightbox/Lightbox.mdx +62 -0
  27. package/src/Lightbox/Lightbox.stories.tsx +133 -0
  28. package/src/Lightbox/Lightbox.test.tsx +135 -0
  29. package/src/Lightbox/Lightbox.tsx +143 -0
  30. package/src/icons.storyHelpers.tsx +22 -0
  31. package/src/index.ts +9 -0
  32. package/src/sourceRules.test.ts +17 -1
@@ -0,0 +1,90 @@
1
+ import type {
2
+ ColorSchemeApplier,
3
+ ColorSchemePersistence,
4
+ ColorSchemeResolver,
5
+ } from "@charcuterie/logic"
6
+ import { useColorScheme } from "@charcuterie/logic"
7
+ import {
8
+ dataSchemeApplier,
9
+ localStoragePersistence,
10
+ matchMediaResolver,
11
+ } from "@charcuterie/logic/browser"
12
+ import type { ReactNode } from "react"
13
+ import { useState } from "react"
14
+
15
+ import type {
16
+ ColorSchemeIcons,
17
+ ColorSchemeToggleProps,
18
+ } from "../ColorSchemeToggle/ColorSchemeToggle.tsx"
19
+ import { ColorSchemeToggle } from "../ColorSchemeToggle/ColorSchemeToggle.tsx"
20
+
21
+ export type ColorSchemeSwitcherProps = Pick<
22
+ ColorSchemeToggleProps,
23
+ | "appearance"
24
+ | "className"
25
+ | "isDisabled"
26
+ | "label"
27
+ | "order"
28
+ | "size"
29
+ > & {
30
+ /** Override where the resolved scheme is written. Default `<html>`. */
31
+ apply?: ColorSchemeApplier
32
+ icons: ColorSchemeIcons
33
+ /** Override persistence. Default `localStorage` under `storageKey`. */
34
+ persistence?: ColorSchemePersistence
35
+ /**
36
+ * Override the OS resolver. Default `matchMedia`. This is the seam
37
+ * the image-viewer passes Electron `nativeTheme` into, and a
38
+ * future React-Native app passes `Appearance` — the whole reason
39
+ * the browser dependency is isolated to this one layer.
40
+ */
41
+ resolver?: ColorSchemeResolver
42
+ storageKey?: string
43
+ }
44
+
45
+ /**
46
+ * The connected colour-scheme switcher — Layer 3, and the only
47
+ * layer that touches the browser.
48
+ *
49
+ * It is a thin wrapper: it wires `useColorScheme` to the three
50
+ * browser defaults from `@charcuterie/logic/browser`
51
+ * (`matchMedia` + `localStorage` + `data-scheme` on `<html>`), and
52
+ * renders the presentational `ColorSchemeToggle`. Every seam is
53
+ * overridable, so a non-browser host swaps `resolver`/`persistence`/
54
+ * `apply` and the same component drives Electron or React-Native.
55
+ *
56
+ * The seams are built once (lazy `useState`) so the `matchMedia`
57
+ * query and the core survive re-renders and StrictMode's double
58
+ * mount.
59
+ */
60
+ export const ColorSchemeSwitcher = ({
61
+ apply,
62
+ icons,
63
+ order,
64
+ persistence,
65
+ resolver,
66
+ storageKey,
67
+ ...toggleProps
68
+ }: ColorSchemeSwitcherProps): ReactNode => {
69
+ const [seams] = useState(() => ({
70
+ apply: apply ?? dataSchemeApplier(),
71
+ persistence:
72
+ persistence ?? localStoragePersistence(storageKey),
73
+ resolver: resolver ?? matchMediaResolver(),
74
+ }))
75
+
76
+ const { cycle, mode } = useColorScheme({
77
+ ...seams,
78
+ order,
79
+ })
80
+
81
+ return (
82
+ <ColorSchemeToggle
83
+ {...toggleProps}
84
+ icons={icons}
85
+ mode={mode}
86
+ onCycle={cycle}
87
+ order={order}
88
+ />
89
+ )
90
+ }
@@ -0,0 +1,48 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ColorSchemeToggleStories from "./ColorSchemeToggle.stories";
4
+
5
+ <Meta of={ColorSchemeToggleStories} />
6
+
7
+ # ColorSchemeToggle
8
+
9
+ The cycling colour-scheme button — **light → dark → system → light**. Layer 2 of three:
10
+ presentational and **controlled**, with no `matchMedia`, `localStorage`, or `document` in
11
+ sight. It renders the current `mode` and reports the next one through `onCycle`; it stores
12
+ nothing.
13
+
14
+ That split is deliberate. The browser dependency lives one layer up in
15
+ [`ColorSchemeSwitcher`](?path=/docs/components-colorschemeswitcher--docs), so this component
16
+ runs unchanged in any runtime — a plain React app wires `onCycle` to `useState`, an Electron
17
+ or React-Native host wires it to `useColorScheme` with its own resolver.
18
+
19
+ <Canvas of={ColorSchemeToggleStories.Default} />
20
+
21
+ ## Props
22
+
23
+ <Controls />
24
+
25
+ ## `mode` vs scheme
26
+
27
+ `mode` is the three-way user preference — `"light" | "dark" | "system"`. It is **not** a
28
+ `data-scheme` value: `data-scheme` only ever holds `"light"` or `"dark"`. Resolving
29
+ `system` to a concrete scheme is the connected layer's job, not this one's.
30
+
31
+ ## Icons are yours
32
+
33
+ The library ships **no icons**
34
+ ([decision](?path=/docs/foundations-decisions--docs)) — `icons` is a `ReactNode` per mode,
35
+ and an app passes lucide `Sun` / `Moon` / `Monitor` (the fleet recommendation). The glyph is
36
+ `aria-hidden`; the button's name comes from `label`, which states the current mode so the
37
+ control is agent-drivable and a screen reader that lands on it hears "Colour scheme: system"
38
+ rather than a nameless button.
39
+
40
+ <Canvas of={ColorSchemeToggleStories.AllModes} />
41
+
42
+ ## Cycling is the only form, for now
43
+
44
+ A segmented, all-three-visible `ColorSchemePicker` was considered and deferred (owner chose
45
+ cycling-only, 2026-08-03). `icons` and `order` are shaped so that form can be added later
46
+ without changing this API.
47
+
48
+ <Canvas of={ColorSchemeToggleStories.Sizes} />
@@ -0,0 +1,161 @@
1
+ import type { ColorSchemeMode } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+
5
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ StoryRow,
11
+ StorySection,
12
+ } from "../board.storyHelpers.tsx"
13
+ import {
14
+ MonitorIcon,
15
+ MoonIcon,
16
+ SunIcon,
17
+ } from "../icons.storyHelpers.tsx"
18
+ import type { ColorSchemeIcons } from "./ColorSchemeToggle.tsx"
19
+ import { ColorSchemeToggle } from "./ColorSchemeToggle.tsx"
20
+
21
+ /**
22
+ * The library ships **no icons** — these are the story-only
23
+ * hand-drawn SVGs an app would replace with lucide `Sun` / `Moon` /
24
+ * `Monitor`.
25
+ */
26
+ const ICONS: ColorSchemeIcons = {
27
+ dark: <MoonIcon />,
28
+ light: <SunIcon />,
29
+ system: <MonitorIcon />,
30
+ }
31
+
32
+ const meta = {
33
+ title: "Components/ColorSchemeToggle",
34
+ component: ColorSchemeToggle,
35
+ parameters: { layout: "padded" },
36
+ argTypes: {
37
+ // `mode` is `ColorSchemeMode` from @charcuterie/logic — a bare
38
+ // specifier docgen cannot enumerate, so it gets an explicit
39
+ // radio rather than falling through to the object control.
40
+ mode: {
41
+ control: "radio",
42
+ options: ["light", "dark", "system"],
43
+ },
44
+ size: controlSizeArgType,
45
+ },
46
+ args: {
47
+ appearance: "ghost",
48
+ icons: ICONS,
49
+ isDisabled: false,
50
+ mode: "system",
51
+ onCycle: () => {},
52
+ size: "md",
53
+ },
54
+ } satisfies Meta<typeof ColorSchemeToggle>
55
+
56
+ export default meta
57
+
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ /**
61
+ * Controlled and self-contained: this story owns the mode in local
62
+ * state and advances it on each press, which is what a plain
63
+ * consumer wires up. `<ColorSchemeSwitcher>` wires the same
64
+ * `onCycle` to the `useColorScheme` hook instead.
65
+ *
66
+ * The accessible name states the current mode and the next one, so
67
+ * it changes as the button cycles — that is what the test asserts.
68
+ */
69
+ export const Default: Story = {
70
+ render: (toggleProps) => {
71
+ const [mode, setMode] =
72
+ useState<ColorSchemeMode>("system")
73
+
74
+ return (
75
+ <ColorSchemeToggle
76
+ {...toggleProps}
77
+ mode={mode}
78
+ onCycle={setMode}
79
+ />
80
+ )
81
+ },
82
+ }
83
+
84
+ /**
85
+ * Each mode's icon, held fixed so the three glyphs are visible at
86
+ * once — light is the sun, dark the moon, system the monitor.
87
+ */
88
+ export const AllModes: Story = {
89
+ render: (toggleProps) => (
90
+ <StorySection title="light → dark → system, the order a press cycles through.">
91
+ <StoryGrid columns={3}>
92
+ <StoryCell label="light">
93
+ <ColorSchemeToggle
94
+ {...toggleProps}
95
+ mode="light"
96
+ onCycle={() => {}}
97
+ />
98
+ </StoryCell>
99
+
100
+ <StoryCell label="dark">
101
+ <ColorSchemeToggle
102
+ {...toggleProps}
103
+ mode="dark"
104
+ onCycle={() => {}}
105
+ />
106
+ </StoryCell>
107
+
108
+ <StoryCell label="system">
109
+ <ColorSchemeToggle
110
+ {...toggleProps}
111
+ mode="system"
112
+ onCycle={() => {}}
113
+ />
114
+ </StoryCell>
115
+ </StoryGrid>
116
+ </StorySection>
117
+ ),
118
+ }
119
+
120
+ export const Sizes: Story = {
121
+ render: (toggleProps) => (
122
+ <StorySection title="Square on the control height, so it lines up with the buttons beside it in a toolbar.">
123
+ <StoryRow>
124
+ <ColorSchemeToggle
125
+ {...toggleProps}
126
+ mode="light"
127
+ onCycle={() => {}}
128
+ size="sm"
129
+ />
130
+
131
+ <ColorSchemeToggle
132
+ {...toggleProps}
133
+ mode="dark"
134
+ onCycle={() => {}}
135
+ size="md"
136
+ />
137
+
138
+ <ColorSchemeToggle
139
+ {...toggleProps}
140
+ mode="system"
141
+ onCycle={() => {}}
142
+ size="lg"
143
+ />
144
+ </StoryRow>
145
+ </StorySection>
146
+ ),
147
+ }
148
+
149
+ export const Responsive: Story = {
150
+ render: (toggleProps) => (
151
+ <ContainerBoard>
152
+ <StoryRow>
153
+ <ColorSchemeToggle
154
+ {...toggleProps}
155
+ mode="system"
156
+ onCycle={() => {}}
157
+ />
158
+ </StoryRow>
159
+ </ContainerBoard>
160
+ ),
161
+ }
@@ -0,0 +1,80 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./ColorSchemeToggle.stories.tsx"
9
+
10
+ const { AllModes, Default } = composeStories(stories)
11
+
12
+ /**
13
+ * The name states the current mode — a screen reader that lands on
14
+ * the control hears "Colour scheme: system", not a nameless button,
15
+ * and an agent can find it.
16
+ */
17
+ test("the toggle is named for its current mode", async () => {
18
+ const { canvas } = await mountStory(Default)
19
+
20
+ expectAgentDrivable(canvas, {
21
+ name: /colour scheme: system/i,
22
+ role: "button",
23
+ })
24
+ })
25
+
26
+ test("a press cycles the mode and updates the name", async () => {
27
+ const { canvas } = await mountStory(Default)
28
+
29
+ const button = expectAgentDrivable(canvas, {
30
+ name: /colour scheme: system/i,
31
+ role: "button",
32
+ })
33
+
34
+ // system → light
35
+ await userEvent.click(button)
36
+
37
+ expectAgentDrivable(canvas, {
38
+ name: /colour scheme: light/i,
39
+ role: "button",
40
+ })
41
+
42
+ // light → dark
43
+ await userEvent.click(button)
44
+
45
+ expectAgentDrivable(canvas, {
46
+ name: /colour scheme: dark/i,
47
+ role: "button",
48
+ })
49
+ })
50
+
51
+ test("the icon is hidden from assistive tech; the name carries it", async () => {
52
+ const { canvas, canvasElement } =
53
+ await mountStory(Default)
54
+
55
+ expectAgentDrivable(canvas, {
56
+ name: /colour scheme/i,
57
+ role: "button",
58
+ })
59
+
60
+ await expectNoAxeViolations(canvasElement)
61
+ })
62
+
63
+ test("each mode renders its own control", async () => {
64
+ const { canvas } = await mountStory(AllModes)
65
+
66
+ expectAgentDrivable(canvas, {
67
+ name: /colour scheme: light/i,
68
+ role: "button",
69
+ })
70
+
71
+ expectAgentDrivable(canvas, {
72
+ name: /colour scheme: dark/i,
73
+ role: "button",
74
+ })
75
+
76
+ expectAgentDrivable(canvas, {
77
+ name: /colour scheme: system/i,
78
+ role: "button",
79
+ })
80
+ })
@@ -0,0 +1,107 @@
1
+ import type { ColorSchemeMode } from "@charcuterie/logic"
2
+ import {
3
+ DEFAULT_COLOR_SCHEME_ORDER,
4
+ nextColorSchemeMode,
5
+ } from "@charcuterie/logic"
6
+ import type { ControlSize } from "@charcuterie/tokens"
7
+ import type { ReactNode } from "react"
8
+
9
+ import { IconButton } from "../IconButton/IconButton.tsx"
10
+ import type { IntentAppearance } from "../intentStyles.ts"
11
+
12
+ /**
13
+ * One `ReactNode` per mode. The library ships **no icons** — lucide
14
+ * (ISC) is the fleet recommendation — so an app passes its own
15
+ * `Sun` / `Moon` / `Monitor`, exactly as `IconButton` takes its
16
+ * glyph as `children`
17
+ * ([decision](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
18
+ */
19
+ export type ColorSchemeIcons = Record<
20
+ ColorSchemeMode,
21
+ ReactNode
22
+ >
23
+
24
+ export type ColorSchemeToggleProps = {
25
+ appearance?: IntentAppearance
26
+ className?: string
27
+ icons: ColorSchemeIcons
28
+ isDisabled?: boolean
29
+ /**
30
+ * Builds the accessible name from the current and next mode.
31
+ * Default announces the current mode and where a press goes —
32
+ * "Colour scheme: dark. Activate to switch to system." — so the
33
+ * name updates as the control cycles and
34
+ * `getByRole("button", { name: /colour scheme/i })` resolves.
35
+ */
36
+ label?: (
37
+ mode: ColorSchemeMode,
38
+ nextMode: ColorSchemeMode,
39
+ ) => string
40
+ mode: ColorSchemeMode
41
+ /**
42
+ * Called with the next mode on activation. **Controlled**: this
43
+ * component stores nothing — it renders `mode` and reports intent,
44
+ * which is what keeps it free of `matchMedia`/`document` and
45
+ * usable in any runtime. `<ColorSchemeSwitcher>` wires this to the
46
+ * `useColorScheme` hook; a plain consumer wires it to its own
47
+ * state.
48
+ */
49
+ onCycle: (nextMode: ColorSchemeMode) => void
50
+ order?: readonly ColorSchemeMode[]
51
+ size?: ControlSize
52
+ }
53
+
54
+ const MODE_LABEL: Record<ColorSchemeMode, string> = {
55
+ dark: "Dark",
56
+ light: "Light",
57
+ system: "System",
58
+ }
59
+
60
+ const defaultLabel = (
61
+ mode: ColorSchemeMode,
62
+ nextMode: ColorSchemeMode,
63
+ ) =>
64
+ `Colour scheme: ${MODE_LABEL[mode]}. Activate to switch to ${MODE_LABEL[nextMode]}.`
65
+
66
+ /**
67
+ * The cycling colour-scheme button — light → dark → system → light.
68
+ *
69
+ * Layer 2 of the three: presentational and controlled, with no
70
+ * browser coupling. It is an `IconButton`, so it inherits the one
71
+ * rule that makes the fleet agent-drivable — a glyph is not a name,
72
+ * and this control's name states the current mode rather than
73
+ * leaving a screen reader to read a sun path as nothing.
74
+ *
75
+ * A *segmented* all-three-visible form was considered and deferred
76
+ * (owner chose cycling-only, 2026-08-03); `icons` and `order` are
77
+ * shaped so a `ColorSchemePicker` could join later without changing
78
+ * this API.
79
+ */
80
+ export const ColorSchemeToggle = ({
81
+ appearance = "ghost",
82
+ className,
83
+ icons,
84
+ isDisabled = false,
85
+ label = defaultLabel,
86
+ mode,
87
+ onCycle,
88
+ order = DEFAULT_COLOR_SCHEME_ORDER,
89
+ size = "md",
90
+ }: ColorSchemeToggleProps): ReactNode => {
91
+ const nextMode = nextColorSchemeMode(mode, order)
92
+
93
+ return (
94
+ <IconButton
95
+ appearance={appearance}
96
+ className={className}
97
+ isDisabled={isDisabled}
98
+ label={label(mode, nextMode)}
99
+ onClick={() => {
100
+ onCycle(nextMode)
101
+ }}
102
+ size={size}
103
+ >
104
+ {icons[mode]}
105
+ </IconButton>
106
+ )
107
+ }
@@ -0,0 +1,62 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as LightboxStories from "./Lightbox.stories";
4
+
5
+ <Meta of={LightboxStories} />
6
+
7
+ # Lightbox
8
+
9
+ A thumbnail that opens its own full-size view. Three apps hand-roll this — a poster,
10
+ a still, a scan the operator wants to read — and none get the focus trap or the scroll
11
+ lock right, because the interesting part is not the overlay, it is the dialog underneath.
12
+
13
+ So a `Lightbox` is a **skin over `Modal`**, not a second dialog. `Modal` owns
14
+ `showModal()`, the top layer, Escape, the `::backdrop` scrim and `lockScrollBehind`;
15
+ this adds the image-specific part — a `cursor-zoom-in` trigger and an `object-contain`
16
+ image clamped to the viewport, so a 2:3 poster and a 16:9 still both land whole.
17
+
18
+ <Canvas of={LightboxStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## The thumbnail is the trigger, and it carries the name
25
+
26
+ Pass the small image as `thumbnail` and the `Lightbox` wraps it in the button that opens
27
+ the overlay. That button carries the accessible name — `Enlarge {alt}` — so the
28
+ `thumbnail` inside it goes `alt=""`, exactly as `MediaTile` names its link and silences
29
+ the image within it. A button around an empty-alt image is otherwise `button-name` with
30
+ nothing to announce.
31
+
32
+ ```tsx
33
+ <Lightbox
34
+ alt="THE OUTFIT poster"
35
+ src={fullSizePoster}
36
+ thumbnail={<img alt="" className="h-40 w-28 object-cover" src={thumbSrc} />}
37
+ />
38
+ ```
39
+
40
+ `alt` also names the dialog, through `heading`'s default. Pass `heading` when the two
41
+ should differ — a filename overlay whose thumbnail alt is already the title.
42
+
43
+ <Canvas of={LightboxStories.WithCaption} />
44
+
45
+ ## Controlled, when something else opens it
46
+
47
+ Omit `thumbnail` and pass `isOpen` / `onOpenChange`, and the `Lightbox` renders only the
48
+ overlay — a button elsewhere on the page owns the open state through its own
49
+ `useVisibility`. The internal store still runs (hooks cannot be conditional) but goes
50
+ unread; the caller is the one truth.
51
+
52
+ <Canvas of={LightboxStories.Controlled} />
53
+
54
+ ## What it inherits, and does not re-test
55
+
56
+ Escape, the focus trap, focus restore to the trigger, the scrim and the scroll lock are
57
+ all `Modal`'s, asserted in `Modal`'s own tests. The lightbox's tests assert only the skin:
58
+ that the thumbnail opens the enlarged image, that the caption rides along, and that the
59
+ contract `Modal` guarantees is not quietly dropped by the wrapper — Escape → `onClose`,
60
+ and focus back to the thumbnail.
61
+
62
+ <Canvas of={LightboxStories.Interactive} />
@@ -0,0 +1,133 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+
5
+ import { Button } from "../Button/Button.tsx"
6
+ import { StoryRow } from "../board.storyHelpers.tsx"
7
+ import { Lightbox } from "./Lightbox.tsx"
8
+
9
+ /**
10
+ * An inline SVG rather than a fixture file or a placeholder service:
11
+ * it decodes synchronously and needs no network, so the enlarged
12
+ * image is deterministic in CI — the same reason `MediaTile`'s
13
+ * stories draw their posters this way.
14
+ */
15
+ const toPosterSrc = (label: string) =>
16
+ `data:image/svg+xml;utf8,${encodeURIComponent(
17
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300"><rect width="200" height="300" fill="#3E38C4"/><text x="100" y="150" fill="#FFFFFF" font-family="sans-serif" font-size="18" text-anchor="middle">${label}</text></svg>`,
18
+ )}`
19
+
20
+ const POSTER = toPosterSrc("THE OUTFIT")
21
+
22
+ /**
23
+ * A small poster thumbnail, exactly as a card would render it —
24
+ * `object-cover` at a fixed poster trim. It is the child the
25
+ * `Lightbox` trigger wraps.
26
+ */
27
+ const PosterThumb = (): ReactNode => (
28
+ <img
29
+ alt=""
30
+ className="h-40 w-28 rounded-md object-cover ring-1 ring-border-subtle"
31
+ src={POSTER}
32
+ />
33
+ )
34
+
35
+ const meta = {
36
+ title: "Components/Lightbox",
37
+ component: Lightbox,
38
+ parameters: { layout: "padded" },
39
+ args: {
40
+ alt: "THE OUTFIT poster",
41
+ src: POSTER,
42
+ },
43
+ } satisfies Meta<typeof Lightbox>
44
+
45
+ export default meta
46
+
47
+ type Story = StoryObj<typeof meta>
48
+
49
+ /**
50
+ * The uncontrolled default: the thumbnail *is* the trigger, and the
51
+ * component owns its open state. This is what a rip card passes —
52
+ * one `Lightbox` with the poster as its `thumbnail`.
53
+ */
54
+ export const Default: Story = {
55
+ args: {
56
+ thumbnail: <PosterThumb />,
57
+ },
58
+ }
59
+
60
+ export const WithCaption: Story = {
61
+ args: {
62
+ caption: "2022 · Blu-ray · 1080p",
63
+ thumbnail: <PosterThumb />,
64
+ },
65
+ }
66
+
67
+ /**
68
+ * Boards here are a trigger plus one open overlay, not a grid — a
69
+ * lightbox is a `Modal` underneath, and two dialogs in the top layer
70
+ * side by side is not a thing the platform can do.
71
+ */
72
+ export const AllStates: Story = {
73
+ render: () => (
74
+ <StoryRow>
75
+ <Lightbox
76
+ alt="THE OUTFIT poster"
77
+ src={POSTER}
78
+ thumbnail={<PosterThumb />}
79
+ />
80
+
81
+ <Lightbox
82
+ alt="THE OUTFIT poster"
83
+ caption="2022 · Blu-ray · 1080p"
84
+ src={POSTER}
85
+ thumbnail={<PosterThumb />}
86
+ />
87
+ </StoryRow>
88
+ ),
89
+ }
90
+
91
+ /**
92
+ * The controlled case: some other control opens the lightbox, and
93
+ * this renders only the overlay. `isOpen` present makes the internal
94
+ * store go unread — the caller's `useVisibility` is the one truth.
95
+ */
96
+ const ControlledDemo = (): ReactNode => {
97
+ const { hide, isVisible, show } = useVisibility()
98
+
99
+ return (
100
+ <>
101
+ <Button appearance="soft" onClick={show} size="sm">
102
+ View the poster
103
+ </Button>
104
+
105
+ <Lightbox
106
+ alt="THE OUTFIT poster"
107
+ caption="Opened from a button, not the image"
108
+ isOpen={isVisible}
109
+ onOpenChange={(isOpen) => {
110
+ if (!isOpen) {
111
+ hide()
112
+ }
113
+ }}
114
+ src={POSTER}
115
+ />
116
+ </>
117
+ )
118
+ }
119
+
120
+ export const Controlled: Story = {
121
+ render: () => <ControlledDemo />,
122
+ }
123
+
124
+ /**
125
+ * The keyboard contract, inherited from `Modal` and asserted here so
126
+ * the skin cannot quietly drop it: the trigger opens, Escape routes
127
+ * through `onClose`, and focus returns to the thumbnail button.
128
+ */
129
+ export const Interactive: Story = {
130
+ args: {
131
+ thumbnail: <PosterThumb />,
132
+ },
133
+ }