@charcuterie/ui 0.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +161 -0
  3. package/dist/Alert/Alert.d.ts +88 -0
  4. package/dist/Alert/Alert.d.ts.map +1 -0
  5. package/dist/Alert/Alert.js +67 -0
  6. package/dist/Alert/Alert.js.map +1 -0
  7. package/dist/Badge/Badge.d.ts +54 -0
  8. package/dist/Badge/Badge.d.ts.map +1 -0
  9. package/dist/Badge/Badge.js +54 -0
  10. package/dist/Badge/Badge.js.map +1 -0
  11. package/dist/Badge/useIsTextClipped.d.ts +22 -0
  12. package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
  13. package/dist/Badge/useIsTextClipped.js +57 -0
  14. package/dist/Badge/useIsTextClipped.js.map +1 -0
  15. package/dist/Button/Button.d.ts +58 -0
  16. package/dist/Button/Button.d.ts.map +1 -0
  17. package/dist/Button/Button.js +37 -0
  18. package/dist/Button/Button.js.map +1 -0
  19. package/dist/Card/Card.d.ts +44 -0
  20. package/dist/Card/Card.d.ts.map +1 -0
  21. package/dist/Card/Card.js +59 -0
  22. package/dist/Card/Card.js.map +1 -0
  23. package/dist/EmptyState/EmptyState.d.ts +42 -0
  24. package/dist/EmptyState/EmptyState.d.ts.map +1 -0
  25. package/dist/EmptyState/EmptyState.js +37 -0
  26. package/dist/EmptyState/EmptyState.js.map +1 -0
  27. package/dist/IconButton/IconButton.d.ts +34 -0
  28. package/dist/IconButton/IconButton.d.ts.map +1 -0
  29. package/dist/IconButton/IconButton.js +23 -0
  30. package/dist/IconButton/IconButton.js.map +1 -0
  31. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
  32. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
  33. package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
  34. package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
  35. package/dist/MediaTile/MediaTile.d.ts +46 -0
  36. package/dist/MediaTile/MediaTile.d.ts.map +1 -0
  37. package/dist/MediaTile/MediaTile.js +79 -0
  38. package/dist/MediaTile/MediaTile.js.map +1 -0
  39. package/dist/MediaTile/mediaStatus.d.ts +22 -0
  40. package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
  41. package/dist/MediaTile/mediaStatus.js +24 -0
  42. package/dist/MediaTile/mediaStatus.js.map +1 -0
  43. package/dist/Modal/Modal.d.ts +66 -0
  44. package/dist/Modal/Modal.d.ts.map +1 -0
  45. package/dist/Modal/Modal.js +111 -0
  46. package/dist/Modal/Modal.js.map +1 -0
  47. package/dist/Modal/lockScrollBehind.d.ts +22 -0
  48. package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
  49. package/dist/Modal/lockScrollBehind.js +48 -0
  50. package/dist/Modal/lockScrollBehind.js.map +1 -0
  51. package/dist/Popover/Popover.d.ts +66 -0
  52. package/dist/Popover/Popover.d.ts.map +1 -0
  53. package/dist/Popover/Popover.js +88 -0
  54. package/dist/Popover/Popover.js.map +1 -0
  55. package/dist/ProgressBar/ProgressBar.d.ts +51 -0
  56. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
  57. package/dist/ProgressBar/ProgressBar.js +50 -0
  58. package/dist/ProgressBar/ProgressBar.js.map +1 -0
  59. package/dist/ProgressBar/progressValue.d.ts +44 -0
  60. package/dist/ProgressBar/progressValue.d.ts.map +1 -0
  61. package/dist/ProgressBar/progressValue.js +55 -0
  62. package/dist/ProgressBar/progressValue.js.map +1 -0
  63. package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
  64. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  65. package/dist/SegmentedControl/SegmentedControl.js +129 -0
  66. package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
  67. package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
  68. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
  69. package/dist/SegmentedControl/SegmentedOption.js +40 -0
  70. package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
  71. package/dist/Skeleton/Skeleton.d.ts +30 -0
  72. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  73. package/dist/Skeleton/Skeleton.js +43 -0
  74. package/dist/Skeleton/Skeleton.js.map +1 -0
  75. package/dist/Spinner/Spinner.d.ts +37 -0
  76. package/dist/Spinner/Spinner.d.ts.map +1 -0
  77. package/dist/Spinner/Spinner.js +33 -0
  78. package/dist/Spinner/Spinner.js.map +1 -0
  79. package/dist/Tabs/TabTrigger.d.ts +32 -0
  80. package/dist/Tabs/TabTrigger.d.ts.map +1 -0
  81. package/dist/Tabs/TabTrigger.js +62 -0
  82. package/dist/Tabs/TabTrigger.js.map +1 -0
  83. package/dist/Tabs/Tabs.d.ts +95 -0
  84. package/dist/Tabs/Tabs.d.ts.map +1 -0
  85. package/dist/Tabs/Tabs.js +195 -0
  86. package/dist/Tabs/Tabs.js.map +1 -0
  87. package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
  88. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  89. package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
  90. package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
  91. package/dist/controlStyles.d.ts +50 -0
  92. package/dist/controlStyles.d.ts.map +1 -0
  93. package/dist/controlStyles.js +60 -0
  94. package/dist/controlStyles.js.map +1 -0
  95. package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
  96. package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
  97. package/dist/expectNoAxeViolations.testHelpers.js +32 -0
  98. package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
  99. package/dist/index.d.ts +75 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +55 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/intentStyles.d.ts +76 -0
  104. package/dist/intentStyles.d.ts.map +1 -0
  105. package/dist/intentStyles.js +150 -0
  106. package/dist/intentStyles.js.map +1 -0
  107. package/dist/mountStory.testHelpers.d.ts +30 -0
  108. package/dist/mountStory.testHelpers.d.ts.map +1 -0
  109. package/dist/mountStory.testHelpers.js +19 -0
  110. package/dist/mountStory.testHelpers.js.map +1 -0
  111. package/dist/statusIntent.d.ts +33 -0
  112. package/dist/statusIntent.d.ts.map +1 -0
  113. package/dist/statusIntent.js +99 -0
  114. package/dist/statusIntent.js.map +1 -0
  115. package/dist/styles.css +130 -0
  116. package/dist/testing/expectAgentDrivable.d.ts +53 -0
  117. package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
  118. package/dist/testing/expectAgentDrivable.js +163 -0
  119. package/dist/testing/expectAgentDrivable.js.map +1 -0
  120. package/dist/testing/index.d.ts +11 -0
  121. package/dist/testing/index.d.ts.map +1 -0
  122. package/dist/testing/index.js +10 -0
  123. package/dist/testing/index.js.map +1 -0
  124. package/dist/toClassName.d.ts +12 -0
  125. package/dist/toClassName.d.ts.map +1 -0
  126. package/dist/toClassName.js +12 -0
  127. package/dist/toClassName.js.map +1 -0
  128. package/dist/tokens.d.ts +16 -0
  129. package/dist/tokens.d.ts.map +1 -0
  130. package/dist/tokens.js +16 -0
  131. package/dist/tokens.js.map +1 -0
  132. package/package.json +70 -0
  133. package/src/Alert/Alert.mdx +70 -0
  134. package/src/Alert/Alert.stories.tsx +218 -0
  135. package/src/Alert/Alert.test.tsx +112 -0
  136. package/src/Alert/Alert.tsx +166 -0
  137. package/src/Badge/Badge.mdx +43 -0
  138. package/src/Badge/Badge.stories.tsx +229 -0
  139. package/src/Badge/Badge.test.tsx +153 -0
  140. package/src/Badge/Badge.tsx +130 -0
  141. package/src/Badge/useIsTextClipped.ts +77 -0
  142. package/src/Button/Button.mdx +44 -0
  143. package/src/Button/Button.stories.tsx +175 -0
  144. package/src/Button/Button.test.tsx +114 -0
  145. package/src/Button/Button.tsx +125 -0
  146. package/src/Card/Card.mdx +53 -0
  147. package/src/Card/Card.stories.tsx +207 -0
  148. package/src/Card/Card.test.tsx +56 -0
  149. package/src/Card/Card.tsx +142 -0
  150. package/src/EmptyState/EmptyState.mdx +40 -0
  151. package/src/EmptyState/EmptyState.stories.tsx +183 -0
  152. package/src/EmptyState/EmptyState.test.tsx +41 -0
  153. package/src/EmptyState/EmptyState.tsx +114 -0
  154. package/src/IconButton/IconButton.mdx +34 -0
  155. package/src/IconButton/IconButton.stories.tsx +191 -0
  156. package/src/IconButton/IconButton.test.tsx +70 -0
  157. package/src/IconButton/IconButton.tsx +54 -0
  158. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
  159. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
  160. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
  161. package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
  162. package/src/MediaTile/MediaTile.mdx +51 -0
  163. package/src/MediaTile/MediaTile.stories.tsx +211 -0
  164. package/src/MediaTile/MediaTile.test.tsx +41 -0
  165. package/src/MediaTile/MediaTile.tsx +227 -0
  166. package/src/MediaTile/mediaStatus.ts +28 -0
  167. package/src/Modal/Modal.mdx +113 -0
  168. package/src/Modal/Modal.stories.tsx +272 -0
  169. package/src/Modal/Modal.test.tsx +248 -0
  170. package/src/Modal/Modal.tsx +228 -0
  171. package/src/Modal/lockScrollBehind.ts +59 -0
  172. package/src/Popover/Popover.mdx +102 -0
  173. package/src/Popover/Popover.stories.tsx +247 -0
  174. package/src/Popover/Popover.test.tsx +191 -0
  175. package/src/Popover/Popover.tsx +164 -0
  176. package/src/ProgressBar/ProgressBar.mdx +48 -0
  177. package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
  178. package/src/ProgressBar/ProgressBar.test.tsx +74 -0
  179. package/src/ProgressBar/ProgressBar.tsx +172 -0
  180. package/src/ProgressBar/progressValue.test.ts +101 -0
  181. package/src/ProgressBar/progressValue.ts +87 -0
  182. package/src/SegmentedControl/SegmentedControl.mdx +71 -0
  183. package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
  184. package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
  185. package/src/SegmentedControl/SegmentedControl.tsx +219 -0
  186. package/src/SegmentedControl/SegmentedOption.tsx +94 -0
  187. package/src/Skeleton/Skeleton.mdx +30 -0
  188. package/src/Skeleton/Skeleton.stories.tsx +130 -0
  189. package/src/Skeleton/Skeleton.test.tsx +47 -0
  190. package/src/Skeleton/Skeleton.tsx +106 -0
  191. package/src/Spinner/Spinner.mdx +36 -0
  192. package/src/Spinner/Spinner.stories.tsx +118 -0
  193. package/src/Spinner/Spinner.test.tsx +38 -0
  194. package/src/Spinner/Spinner.tsx +92 -0
  195. package/src/Tabs/TabTrigger.tsx +126 -0
  196. package/src/Tabs/Tabs.mdx +126 -0
  197. package/src/Tabs/Tabs.stories.tsx +187 -0
  198. package/src/Tabs/Tabs.test.tsx +283 -0
  199. package/src/Tabs/Tabs.tsx +333 -0
  200. package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
  201. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
  202. package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  203. package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
  204. package/src/argTypes.storyHelpers.ts +115 -0
  205. package/src/board.storyHelpers.tsx +150 -0
  206. package/src/controlStyles.ts +79 -0
  207. package/src/expectNoAxeViolations.testHelpers.ts +41 -0
  208. package/src/icons.storyHelpers.tsx +116 -0
  209. package/src/index.ts +132 -0
  210. package/src/intentStyles.ts +207 -0
  211. package/src/mdxReferences.test.ts +95 -0
  212. package/src/mountStory.testHelpers.ts +41 -0
  213. package/src/sourceRules.test.ts +303 -0
  214. package/src/statusIntent.test.ts +136 -0
  215. package/src/statusIntent.ts +137 -0
  216. package/src/storyControls.test.ts +128 -0
  217. package/src/styles.css +130 -0
  218. package/src/tailwindCandidates.test.ts +377 -0
  219. package/src/testing/expectAgentDrivable.test.tsx +71 -0
  220. package/src/testing/expectAgentDrivable.ts +273 -0
  221. package/src/testing/index.ts +17 -0
  222. package/src/toClassName.ts +13 -0
  223. package/src/tokens.ts +16 -0
@@ -0,0 +1,116 @@
1
+ import type { ConnectionStatus } from "@charcuterie/logic"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+
7
+ import { DOT_SIZE_CLASS } from "../controlStyles.ts"
8
+ import {
9
+ INTENT_CONTENT_CLASS,
10
+ INTENT_SOLID_FILL_CLASS,
11
+ } from "../intentStyles.ts"
12
+ import {
13
+ getConnectionIntent,
14
+ getConnectionLabel,
15
+ getIsConnectionBusy,
16
+ } from "../statusIntent.ts"
17
+ import { toClassName } from "../toClassName.ts"
18
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
19
+
20
+ export type LiveStatusIndicatorSize =
21
+ keyof typeof DOT_SIZE_CLASS
22
+
23
+ export type LiveStatusIndicatorProps = Omit<
24
+ ComponentPropsWithRef<"span">,
25
+ "children"
26
+ > & {
27
+ /** Extra context, announced after the label. "3 of 4 bays." */
28
+ detail?: string
29
+ isLabelVisible?: boolean
30
+ /** Overrides the machine's own wording. Rarely wanted. */
31
+ label?: string
32
+ size?: LiveStatusIndicatorSize
33
+ status: ConnectionStatus
34
+ }
35
+
36
+ /**
37
+ * Four repos, four different implementations, and **it is the thing
38
+ * users read most** — a wall-mounted panel showing stale data with a
39
+ * green dot is worse than one showing nothing.
40
+ *
41
+ * The second reason it is P0: all four collapse `connecting` and
42
+ * `reconnecting` into one state, which reads wrong on exactly the
43
+ * link that needs it. A user who has seen data wants "lost it,
44
+ * getting it back" (amber, moving), not the cold-start spinner they
45
+ * already sat through (blue) and not a dead link (red). That
46
+ * distinction is why `connectionTransitions` is one of the two
47
+ * machines `@charcuterie/logic` ships rather than something each app
48
+ * defines, and `getConnectionIntent` / `getConnectionLabel` are
49
+ * exhaustive switches over it — add a fifth state and this component
50
+ * stops compiling instead of rendering a grey dot.
51
+ *
52
+ * `role="status"` + `aria-live="polite"` on the wrapper, so a
53
+ * transition is announced without interrupting whatever the user is
54
+ * doing. `aria-live="assertive"` would be wrong here even for
55
+ * `disconnected`: it cuts off the sentence in progress, and the dot
56
+ * is not going anywhere.
57
+ */
58
+ export const LiveStatusIndicator = ({
59
+ className,
60
+ detail,
61
+ isLabelVisible = true,
62
+ label,
63
+ size = "md",
64
+ status,
65
+ ...spanProps
66
+ }: LiveStatusIndicatorProps): ReactNode => {
67
+ const intent = getConnectionIntent(status)
68
+
69
+ const statusLabel = label ?? getConnectionLabel(status)
70
+
71
+ return (
72
+ <span
73
+ {...spanProps}
74
+ // Named as well as live, for the same reason as `Spinner`: a
75
+ // `status` region gets no name from its content, so this is
76
+ // what makes `getByRole("status", { name: "Connected" })` — the
77
+ // query an agent will actually write — resolve.
78
+ aria-label={statusLabel}
79
+ aria-live="polite"
80
+ className={toClassName(
81
+ "inline-flex items-center gap-2 text-sm",
82
+ INTENT_CONTENT_CLASS[intent],
83
+ className,
84
+ )}
85
+ role="status"
86
+ // Not an ARIA attribute — a hook for a consumer's own CSS and
87
+ // for a Playwright assertion that wants the state rather than
88
+ // the wording, which survives translation.
89
+ data-status={status}
90
+ >
91
+ <span
92
+ aria-hidden="true"
93
+ className={toClassName(
94
+ "inline-block shrink-0 rounded-full",
95
+ DOT_SIZE_CLASS[size],
96
+ INTENT_SOLID_FILL_CLASS[intent],
97
+ // Only the two in-flight states move. A pulsing dot that
98
+ // never stops is the fleet's current "is it alive?"
99
+ // problem in a new costume.
100
+ getIsConnectionBusy(status) &&
101
+ "charcuterie-pulse",
102
+ )}
103
+ />
104
+
105
+ {isLabelVisible ? (
106
+ <span>{statusLabel}</span>
107
+ ) : (
108
+ <VisuallyHidden>{statusLabel}</VisuallyHidden>
109
+ )}
110
+
111
+ {detail ? (
112
+ <span className="text-content-muted">{detail}</span>
113
+ ) : null}
114
+ </span>
115
+ )
116
+ }
@@ -0,0 +1,51 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MediaTileStories from "./MediaTile.stories";
4
+
5
+ <Meta of={MediaTileStories} />
6
+
7
+ # MediaTile
8
+
9
+ Three repos, and shipping one of these is what makes plex-channels, image-viewer, and
10
+ gallery-downloader look like one family instead of three.
11
+
12
+ <Canvas of={MediaTileStories.Grid} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## The three states no hand-rolled version models
19
+
20
+ | State | What it does | Why |
21
+ | --- | --- | --- |
22
+ | loading | `Skeleton` at the tile's exact aspect ratio | A 200-poster grid that reflows on load loses the user's place |
23
+ | error | Keeps the box, keeps the name (`role="img"` with the same `alt`) | A broken `<img>` collapses to a glyph and takes the row height with it |
24
+ | cached | Checks `complete`/`naturalWidth` on mount and on every `src` change | A `complete` image never fires `load`, so an `onLoad`-only tile shimmers over a perfectly good poster forever |
25
+
26
+ Those are a `useStatus` machine rather than a `useState<string>`, which is what makes
27
+ `loaded → error` impossible instead of merely unlikely — see `mediaStatus.ts`. A browser
28
+ does not fire `error` on an image it has already decoded, so an app that thinks it saw that
29
+ is misreading its own event wiring, and the machine throws rather than flickering a fallback
30
+ over a good poster.
31
+
32
+ <Canvas of={MediaTileStories.AllStates} />
33
+
34
+ ## Sizing
35
+
36
+ The tile is a container query (the caption's type steps up past `--cq-sm`), which means it
37
+ may not be sized by its own contents: put it in a grid track or give it a definite inline
38
+ size. In a shrink-to-fit parent it collapses to min-content — the same constraint `Card`
39
+ carries, and for the same reason.
40
+
41
+ ## Names
42
+
43
+ `alt` is required. When `href` is set, the link carries `aria-label={title}` and the image
44
+ goes `alt=""` — an agent writing `getByRole("link", { name: "Blade Runner" })` should not
45
+ have to know whether a subtitle happened to be rendered, and two names for one link is a
46
+ screen reader reading it twice.
47
+
48
+ The caption's type steps up past `--cq-sm`, so a tile in a dense 8-across grid and the same
49
+ tile in a 3-across one are both legible.
50
+
51
+ <Canvas of={MediaTileStories.Responsive} />
@@ -0,0 +1,211 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { Badge } from "../Badge/Badge.tsx"
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { MediaTile } from "./MediaTile.tsx"
11
+
12
+ /**
13
+ * An inline SVG rather than a fixture file or a placeholder service:
14
+ * it decodes synchronously, needs no network, and makes the
15
+ * `loaded` state deterministic in CI — which matters because the
16
+ * *cached* path is one of the three states this component exists to
17
+ * get right.
18
+ */
19
+ const toPosterSrc = (label: string) =>
20
+ `data:image/svg+xml;utf8,${encodeURIComponent(
21
+ `<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>`,
22
+ )}`
23
+
24
+ /** 404s, which is what makes the error state real rather than mocked. */
25
+ const MISSING_SRC = "/charcuterie-missing-poster.png"
26
+
27
+ const meta = {
28
+ title: "Components/MediaTile",
29
+ component: MediaTile,
30
+ parameters: { layout: "padded" },
31
+ args: { aspectRatio: "poster" },
32
+ } satisfies Meta<typeof MediaTile>
33
+
34
+ export default meta
35
+
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ export const Default: Story = {
39
+ args: {
40
+ alt: "Blade Runner (1982) poster",
41
+ src: toPosterSrc("Blade Runner"),
42
+ subtitle: "1982 · 4K remaster",
43
+ title: "Blade Runner",
44
+ },
45
+ }
46
+
47
+ export const AllVariants: Story = {
48
+ args: { alt: "Poster", title: "Blade Runner" },
49
+ render: () => (
50
+ <StorySection title="Three trims. 2:3 is the poster standard — Plex, Kavita, and every physical sleeve in the collection.">
51
+ <StoryGrid columns={3}>
52
+ <StoryCell align="stretch" label="poster (2:3)">
53
+ <MediaTile
54
+ alt="Blade Runner (1982) poster"
55
+ src={toPosterSrc("2:3")}
56
+ subtitle="1982"
57
+ title="Blade Runner"
58
+ />
59
+ </StoryCell>
60
+
61
+ <StoryCell align="stretch" label="square">
62
+ <MediaTile
63
+ alt="Kind of Blue album art"
64
+ aspectRatio="square"
65
+ src={toPosterSrc("1:1")}
66
+ subtitle="Miles Davis"
67
+ title="Kind of Blue"
68
+ />
69
+ </StoryCell>
70
+
71
+ <StoryCell align="stretch" label="video (16:9)">
72
+ <MediaTile
73
+ alt="Episode thumbnail"
74
+ aspectRatio="video"
75
+ src={toPosterSrc("16:9")}
76
+ subtitle="S01E04"
77
+ title="The Constant"
78
+ />
79
+ </StoryCell>
80
+ </StoryGrid>
81
+ </StorySection>
82
+ ),
83
+ }
84
+
85
+ /**
86
+ * The three states are the component, and no hand-rolled version in
87
+ * the fleet models more than one of them.
88
+ */
89
+ export const AllStates: Story = {
90
+ args: { alt: "Poster", title: "Blade Runner" },
91
+ render: () => (
92
+ <StoryGrid columns={4}>
93
+ <StoryCell align="stretch" label="loaded">
94
+ <MediaTile
95
+ alt="Blade Runner (1982) poster"
96
+ src={toPosterSrc("Blade Runner")}
97
+ subtitle="1982 · 4K remaster"
98
+ title="Blade Runner"
99
+ />
100
+ </StoryCell>
101
+
102
+ <StoryCell
103
+ align="stretch"
104
+ label="loading — skeleton holds the box"
105
+ >
106
+ <MediaTile
107
+ alt="Poster loading"
108
+ subtitle="waiting for the image"
109
+ title="Still loading"
110
+ />
111
+ </StoryCell>
112
+
113
+ <StoryCell
114
+ align="stretch"
115
+ label="error — box and name survive"
116
+ >
117
+ <MediaTile
118
+ alt="Dune (2021) poster"
119
+ src={MISSING_SRC}
120
+ subtitle="2021"
121
+ title="Dune"
122
+ />
123
+ </StoryCell>
124
+
125
+ <StoryCell align="stretch" label="link + badge">
126
+ <MediaTile
127
+ alt="Blade Runner (1982) poster"
128
+ badge={
129
+ <Badge intent="success" size="sm">
130
+ 4K
131
+ </Badge>
132
+ }
133
+ href="#blade-runner"
134
+ src={toPosterSrc("Blade Runner")}
135
+ subtitle="1982 · 4K remaster"
136
+ title="Blade Runner"
137
+ />
138
+ </StoryCell>
139
+ </StoryGrid>
140
+ ),
141
+ }
142
+
143
+ /**
144
+ * The caption's type steps up past `--cq-sm`, so a tile in a dense
145
+ * 8-across grid and the same tile in a 3-across one are legible at
146
+ * both — the intermediate widths where the fleet's poster grids look
147
+ * wrong today.
148
+ */
149
+ export const Responsive: Story = {
150
+ args: { alt: "Poster", title: "Blade Runner" },
151
+ render: () => (
152
+ <ContainerBoard>
153
+ <MediaTile
154
+ alt="Blade Runner (1982) poster"
155
+ src={toPosterSrc("Blade Runner")}
156
+ subtitle="1982 · 4K remaster"
157
+ title="Blade Runner"
158
+ />
159
+ </ContainerBoard>
160
+ ),
161
+ }
162
+
163
+ export const Grid: Story = {
164
+ args: { alt: "Poster", title: "Blade Runner" },
165
+ render: () => (
166
+ // Two elements, not one: the container and the grid that queries
167
+ // it **must** be different, because a container query matches
168
+ // descendants only. Collapsed into one `<div>` this compiles,
169
+ // generates real CSS, and silently stays two columns — which on a
170
+ // desktop is four 700px posters.
171
+ <div className="@container">
172
+ <div className="grid grid-cols-2 gap-4 cq-sm:grid-cols-3 cq-md:grid-cols-4 cq-lg:grid-cols-6">
173
+ {[
174
+ "Blade Runner",
175
+ "Dune",
176
+ "Arrival",
177
+ "Solaris",
178
+ "Stalker",
179
+ "Annihilation",
180
+ ].map((title, index) => (
181
+ <MediaTile
182
+ alt={`${title} poster`}
183
+ href={`#${title.toLowerCase().replace(" ", "-")}`}
184
+ key={title}
185
+ src={
186
+ index === 1 ? MISSING_SRC : toPosterSrc(title)
187
+ }
188
+ subtitle="Science fiction"
189
+ title={title}
190
+ />
191
+ ))}
192
+ </div>
193
+ </div>
194
+ ),
195
+ }
196
+
197
+ /**
198
+ * The linked tile's contract: the accessible name is exactly the
199
+ * title — not the title plus the subtitle plus the alt text — and
200
+ * Tab reaches it. `getByRole("link", { name: "Blade Runner" })` is
201
+ * what an agent will actually write.
202
+ */
203
+ export const Interactive: Story = {
204
+ args: {
205
+ alt: "Blade Runner (1982) poster",
206
+ href: "#blade-runner",
207
+ src: toPosterSrc("Blade Runner"),
208
+ subtitle: "1982 · 4K remaster",
209
+ title: "Blade Runner",
210
+ },
211
+ }
@@ -0,0 +1,41 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import { expectAgentDrivable } from "../testing/index.ts"
7
+ import * as stories from "./MediaTile.stories.tsx"
8
+
9
+ const { Default, Interactive } = composeStories(stories)
10
+
11
+ test("an unlinked tile's poster carries the name", async () => {
12
+ const { canvas } = await mountStory(Default)
13
+
14
+ expectAgentDrivable(canvas, {
15
+ name: "Blade Runner (1982) poster",
16
+ role: "img",
17
+ })
18
+ })
19
+
20
+ /**
21
+ * The linked tile's contract: the accessible name is exactly the
22
+ * title — not the title plus the subtitle plus the alt text — and
23
+ * Tab reaches it. `getByRole("link", { name: "Blade Runner" })` is
24
+ * what an agent will actually write.
25
+ */
26
+ test("a linked tile is one link named for its title", async () => {
27
+ const { canvas } = await mountStory(Interactive)
28
+
29
+ const link = expectAgentDrivable(canvas, {
30
+ name: "Blade Runner",
31
+ role: "link",
32
+ })
33
+
34
+ await userEvent.tab()
35
+
36
+ await expect(link).toHaveFocus()
37
+
38
+ // The image inside a linked tile is `alt=""` on purpose: two names
39
+ // for one link is a screen reader reading it twice.
40
+ await expect(canvas.queryAllByRole("img")).toHaveLength(0)
41
+ })
@@ -0,0 +1,227 @@
1
+ import { useStatus } from "@charcuterie/logic"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+ import { useEffect, useRef } from "react"
7
+
8
+ import { Skeleton } from "../Skeleton/Skeleton.tsx"
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { mediaTransitions } from "./mediaStatus.ts"
11
+
12
+ export type MediaTileRatio = "poster" | "square" | "video"
13
+
14
+ export type MediaTileProps = Omit<
15
+ ComponentPropsWithRef<"figure">,
16
+ "children"
17
+ > & {
18
+ /**
19
+ * Required. The one thing three hand-rolled poster grids all
20
+ * omit — and a 200-tile page of unnamed images is unnavigable
21
+ * rather than merely imperfect.
22
+ */
23
+ alt: string
24
+ aspectRatio?: MediaTileRatio
25
+ /** A `Badge`, usually. Pinned inside the media box. */
26
+ badge?: ReactNode
27
+ /** Shown in place of the image when it fails. */
28
+ fallback?: ReactNode
29
+ href?: string
30
+ src?: string
31
+ subtitle?: ReactNode
32
+ title: string
33
+ }
34
+
35
+ const RATIO_CLASS: Record<MediaTileRatio, string> = {
36
+ // 2:3 is the standard poster trim, which is what Plex, Kavita, and
37
+ // every physical sleeve in the collection actually are.
38
+ poster: "aspect-[2/3]",
39
+ square: "aspect-square",
40
+ video: "aspect-video",
41
+ }
42
+
43
+ /**
44
+ * Three repos, and shipping one of these is what makes
45
+ * plex-channels, image-viewer, and gallery-downloader look like one
46
+ * family instead of three.
47
+ *
48
+ * The interesting part is not the layout, it is the three states an
49
+ * image has and the fact that no hand-rolled version models them:
50
+ *
51
+ * - **Loading** shows a `Skeleton` at the tile's exact aspect ratio,
52
+ * so the grid does not reflow when 200 posters land. Layout shift
53
+ * is the thing users notice in these apps today.
54
+ * - **Error** keeps the tile's box and its name. An `<img>` with a
55
+ * broken `src` collapses to a glyph and takes the row's height
56
+ * with it; the fallback here is a `role="img"` carrying the same
57
+ * `alt`, so the tile stays findable and the grid stays a grid.
58
+ * - **Cached** is the case that bites: a `complete` image never
59
+ * fires `load`, so a naive `onLoad`-only implementation leaves a
60
+ * shimmering skeleton over a perfectly rendered poster forever.
61
+ * The effect below checks `complete`/`naturalWidth` on mount and
62
+ * on every `src` change.
63
+ *
64
+ * Those states are a `useStatus` machine rather than a
65
+ * `useState<string>`, which is what makes `loaded → error`
66
+ * impossible instead of merely unlikely — see `mediaStatus.ts`.
67
+ */
68
+ export const MediaTile = ({
69
+ alt,
70
+ aspectRatio = "poster",
71
+ badge,
72
+ className,
73
+ fallback,
74
+ href,
75
+ src,
76
+ subtitle,
77
+ title,
78
+ ...figureProps
79
+ }: MediaTileProps): ReactNode => {
80
+ const imageRef = useRef<HTMLImageElement>(null)
81
+
82
+ const { can, reset, status, transitionTo } = useStatus({
83
+ initialState: "loading",
84
+ transitions: mediaTransitions,
85
+ })
86
+
87
+ useEffect(() => {
88
+ // A new `src` is a new load, including after a failure.
89
+ reset()
90
+
91
+ const image = imageRef.current
92
+
93
+ if (!image?.complete) {
94
+ return
95
+ }
96
+
97
+ // `complete` with no intrinsic width is a *failed* cached
98
+ // load, not a successful one — the distinction browsers only
99
+ // expose this way.
100
+ if (image.naturalWidth > 0) {
101
+ if (can("loaded")) {
102
+ transitionTo("loaded")
103
+ }
104
+
105
+ return
106
+ }
107
+
108
+ if (can("error")) {
109
+ transitionTo("error")
110
+ }
111
+ }, [can, reset, transitionTo])
112
+
113
+ const media = (
114
+ <div
115
+ className={toClassName(
116
+ "relative overflow-hidden rounded-md bg-surface-sunken",
117
+ RATIO_CLASS[aspectRatio],
118
+ )}
119
+ >
120
+ {status === "error" ? (
121
+ <div
122
+ // Unconditionally an `img` carrying the same `alt`, even
123
+ // inside a link. The link's own `aria-label` is a name from
124
+ // the author and takes precedence, so this cannot
125
+ // double-name it — and a conditional role would leave the
126
+ // failure case nameless in exactly the arrangement (a
127
+ // poster grid of links) where it happens most.
128
+ aria-label={alt}
129
+ className="flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-content-muted text-xs"
130
+ role="img"
131
+ >
132
+ {/* Words, not a glyph. The library ships zero SVGs by
133
+ rule, and a symbol character is not a safe substitute:
134
+ `▨` depends on the *system* having a font that covers
135
+ it, which the kiosk Pis and this repo's headless
136
+ Chromium do not — so the honest default is text, and an
137
+ app with an icon set passes `fallback`. */}
138
+ {fallback ?? <span>Image unavailable</span>}
139
+ </div>
140
+ ) : (
141
+ <>
142
+ {status === "loading" ? (
143
+ <Skeleton className="absolute inset-0 size-full" />
144
+ ) : null}
145
+
146
+ <img
147
+ alt={href ? "" : alt}
148
+ className={toClassName(
149
+ "size-full object-cover transition-opacity duration-(--duration-normal) ease-standard",
150
+ status === "loaded"
151
+ ? "opacity-100"
152
+ : "opacity-0",
153
+ )}
154
+ decoding="async"
155
+ loading="lazy"
156
+ onError={() => {
157
+ if (can("error")) {
158
+ transitionTo("error")
159
+ }
160
+ }}
161
+ onLoad={() => {
162
+ if (can("loaded")) {
163
+ transitionTo("loaded")
164
+ }
165
+ }}
166
+ ref={imageRef}
167
+ src={src}
168
+ />
169
+ </>
170
+ )}
171
+
172
+ {badge ? (
173
+ <div className="absolute top-2 start-2">
174
+ {badge}
175
+ </div>
176
+ ) : null}
177
+ </div>
178
+ )
179
+
180
+ const caption = (
181
+ <figcaption className="flex flex-col gap-0.5 pt-2">
182
+ <span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
183
+ {title}
184
+ </span>
185
+
186
+ {subtitle ? (
187
+ <span className="truncate text-content-muted text-xs">
188
+ {subtitle}
189
+ </span>
190
+ ) : null}
191
+ </figcaption>
192
+ )
193
+
194
+ return (
195
+ <figure
196
+ {...figureProps}
197
+ className={toClassName(
198
+ "@container flex flex-col",
199
+ className,
200
+ )}
201
+ >
202
+ {href ? (
203
+ // `aria-label` rather than the caption text, so the link's
204
+ // name is exactly the title — an agent matching
205
+ // `getByRole("link", { name: "Blade Runner" })` should not
206
+ // have to know whether a subtitle happened to be rendered.
207
+ // The `<img>` inside goes `alt=""` for the same reason: two
208
+ // names for one link is a screen reader reading it twice.
209
+ <a
210
+ aria-label={title}
211
+ className="group flex flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
212
+ href={href}
213
+ >
214
+ {media}
215
+
216
+ {caption}
217
+ </a>
218
+ ) : (
219
+ <>
220
+ {media}
221
+
222
+ {caption}
223
+ </>
224
+ )}
225
+ </figure>
226
+ )
227
+ }
@@ -0,0 +1,28 @@
1
+ import type { StatusTransitions } from "@charcuterie/logic"
2
+
3
+ export type MediaStatus = "error" | "loaded" | "loading"
4
+
5
+ /**
6
+ * A component-owned machine, and the smallest honest example of what
7
+ * `createStatus` is for.
8
+ *
9
+ * `@charcuterie/logic` ships only the two machines that are
10
+ * genuinely fleet-wide — connection and async request — precisely so
11
+ * that a machine like this one lives next to the thing it describes.
12
+ * Three states, two of them terminal-ish, and the edges matter:
13
+ *
14
+ * - `loaded → loading` and `error → loading` exist for a changed
15
+ * `src`. Without them a poster grid that repaints with new URLs
16
+ * keeps showing the old tile's error.
17
+ * - `loaded → error` does **not** exist. A browser does not fire
18
+ * `error` on an image it has already decoded, and an app that
19
+ * thinks it saw that is misreading its own event wiring — so the
20
+ * machine throws rather than flickering a fallback over a
21
+ * perfectly good poster.
22
+ */
23
+ export const mediaTransitions: StatusTransitions<MediaStatus> =
24
+ {
25
+ error: ["loading"],
26
+ loaded: ["loading"],
27
+ loading: ["error", "loaded"],
28
+ }