@charcuterie/ui 4.0.0 → 4.2.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 (54) hide show
  1. package/README.md +3 -2
  2. package/dist/ActionTiles/ActionTiles.d.ts +66 -8
  3. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
  4. package/dist/ActionTiles/ActionTiles.js +88 -11
  5. package/dist/ActionTiles/ActionTiles.js.map +1 -1
  6. package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
  7. package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
  8. package/dist/PortraitTiles/PortraitAvatar.js +29 -0
  9. package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
  10. package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
  11. package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
  12. package/dist/PortraitTiles/PortraitTiles.js +263 -0
  13. package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
  14. package/dist/PortraitTiles/portraitColour.d.ts +49 -0
  15. package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
  16. package/dist/PortraitTiles/portraitColour.js +55 -0
  17. package/dist/PortraitTiles/portraitColour.js.map +1 -0
  18. package/dist/RouterLink/UnstyledLink.d.ts +17 -0
  19. package/dist/RouterLink/UnstyledLink.d.ts.map +1 -0
  20. package/dist/RouterLink/UnstyledLink.js +24 -0
  21. package/dist/RouterLink/UnstyledLink.js.map +1 -0
  22. package/dist/categoricalStyles.d.ts +27 -0
  23. package/dist/categoricalStyles.d.ts.map +1 -1
  24. package/dist/categoricalStyles.js +49 -0
  25. package/dist/categoricalStyles.js.map +1 -1
  26. package/dist/index.d.ts +6 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/tileStyles.d.ts +15 -0
  31. package/dist/tileStyles.d.ts.map +1 -1
  32. package/dist/tileStyles.js +26 -1
  33. package/dist/tileStyles.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/ActionTiles/ActionTiles.mdx +82 -8
  36. package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
  37. package/src/ActionTiles/ActionTiles.test.tsx +205 -5
  38. package/src/ActionTiles/ActionTiles.tsx +274 -110
  39. package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
  40. package/src/PortraitTiles/PortraitTiles.mdx +153 -0
  41. package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
  42. package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
  43. package/src/PortraitTiles/PortraitTiles.tsx +577 -0
  44. package/src/PortraitTiles/portraitColour.test.ts +39 -0
  45. package/src/PortraitTiles/portraitColour.ts +67 -0
  46. package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
  47. package/src/RouterLink/RouterLink.mdx +25 -4
  48. package/src/RouterLink/UnstyledLink.stories.tsx +57 -0
  49. package/src/RouterLink/UnstyledLink.test.tsx +40 -0
  50. package/src/RouterLink/UnstyledLink.tsx +34 -0
  51. package/src/categoricalStyles.ts +57 -0
  52. package/src/index.ts +11 -0
  53. package/src/sourceRules.test.ts +17 -4
  54. package/src/tileStyles.ts +30 -1
@@ -139,6 +139,45 @@ export const Tiles: Story = {
139
139
  },
140
140
  }
141
141
 
142
+ /**
143
+ * The tile at all three sizes.
144
+ *
145
+ * `AllVariants` above covers the three heights of a **row**, and
146
+ * every tile story took the default `md` — so the largest tile had
147
+ * no snapshot at all, and `TILE_PADDING_CLASS.lg` could be changed
148
+ * without a single pixel moving in the report. It was, on
149
+ * 2026-09-02, when `lg` grew to a landing-page tile's padding.
150
+ *
151
+ * The padding is shared with `ActionTiles` through `tileStyles.ts`,
152
+ * which is the promise this board is here to photograph.
153
+ */
154
+ export const TileSizes: Story = {
155
+ args: {
156
+ items: IMPORT_ITEMS,
157
+ itemShape: "tile",
158
+ label: "On import",
159
+ },
160
+ render: (controlProps) => (
161
+ <StorySection title="One box at three sizes, and the same box ActionTiles reads from.">
162
+ <StoryGrid columns={3}>
163
+ {(["sm", "md", "lg"] as const).map((size) => (
164
+ <StoryCell
165
+ align="stretch"
166
+ key={size}
167
+ label={size}
168
+ >
169
+ <RadioGroup
170
+ {...controlProps}
171
+ label={`On import at ${size}`}
172
+ size={size}
173
+ />
174
+ </StoryCell>
175
+ ))}
176
+ </StoryGrid>
177
+ </StorySection>
178
+ ),
179
+ }
180
+
142
181
  /**
143
182
  * A tile may lead with an icon. The library ships none — these are
144
183
  * story-only SVGs, exactly what an app passes — and the icon is
@@ -4,10 +4,11 @@ import { Meta } from "@storybook/addon-docs/blocks";
4
4
 
5
5
  # Routing
6
6
 
7
- `TextLink` and `ButtonLink` render a real `<a href>`. That is already correct with no setup
8
- at all — it navigates, it can be middle-clicked, ctrl-clicked, opened in a new tab and
9
- copied — it just reloads the page. **One provider at the app root** upgrades every link in
10
- the tree to the app's own soft navigation, with no change at any call site.
7
+ `TextLink`, `ButtonLink`, and `UnstyledLink` render a real `<a href>`. That is already
8
+ correct with no setup at all — it navigates, it can be middle-clicked, ctrl-clicked, opened
9
+ in a new tab and copied — it just reloads the page. **One provider at the app root**
10
+ upgrades every link in the tree to the app's own soft navigation, with no change at any
11
+ call site.
11
12
 
12
13
  ## Why the library does not just import a router
13
14
 
@@ -107,6 +108,25 @@ export const ChannelCard = ({ channel }) => (
107
108
  )
108
109
  ```
109
110
 
111
+ `TextLink` owns the fleet's inline and standalone text-link paint. `ButtonLink` owns the
112
+ button-shaped paint. When an app already owns the complete appearance and must preserve it,
113
+ `UnstyledLink` supplies only the same routing behavior:
114
+
115
+ ```tsx
116
+ import { UnstyledLink } from "@charcuterie/ui"
117
+
118
+ <UnstyledLink
119
+ className="text-xs text-content-secondary hover:text-content-primary"
120
+ href="/jobs"
121
+ >
122
+ Jobs
123
+ </UnstyledLink>
124
+ ```
125
+
126
+ It adds no class and changes no target, relation, or accessible name. Prefer the two painted
127
+ components for new UI. Use `UnstyledLink` for an established app-owned treatment whose
128
+ appearance is not part of the change.
129
+
110
130
  ## Any other router
111
131
 
112
132
  The seam's contract is **a component that takes `href`**, so an adapter is a rename. There
@@ -208,6 +228,7 @@ what `Main.stories.tsx` does.
208
228
  | `useRouterLink()` | `@charcuterie/ui` | The injected component, or `AnchorLink`. For an app building its own link-shaped component. |
209
229
  | `RouterLinkComponent` / `RouterLinkProps` | `@charcuterie/ui` | The contract: `ComponentPropsWithRef<"a">` with `href` required. |
210
230
  | `AnchorLink` | `@charcuterie/ui` | The plain `<a href>` default. |
231
+ | `UnstyledLink` | `@charcuterie/ui` | The router-aware link seam with no library paint. |
211
232
  | `getIsRoutedHref(href)` | `@charcuterie/ui` | The destination test above. |
212
233
  | `ScrollMemoryProvider` | `@charcuterie/ui` | The scroll seam. `entry` is `{ key, path }` for the current history entry. |
213
234
  | `ScrollEntry` | `@charcuterie/ui` | That pair's type. |
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { RouterLinkProvider } from "./RouterLinkProvider.tsx"
5
+ import type { RouterLinkProps } from "./routerLink.ts"
6
+ import { UnstyledLink } from "./UnstyledLink.tsx"
7
+
8
+ const SoftRouterLink = ({
9
+ href,
10
+ ...linkProps
11
+ }: RouterLinkProps): ReactNode => (
12
+ <a {...linkProps} data-router="soft" href={href} />
13
+ )
14
+
15
+ const meta = {
16
+ title: "Utilities/UnstyledLink",
17
+ component: UnstyledLink,
18
+ parameters: { layout: "padded" },
19
+ args: {
20
+ children: "Jobs",
21
+ className:
22
+ "text-sm text-intent-accent-content hover:underline",
23
+ href: "/jobs",
24
+ },
25
+ } satisfies Meta<typeof UnstyledLink>
26
+
27
+ export default meta
28
+
29
+ type Story = StoryObj<typeof meta>
30
+
31
+ export const Default: Story = {}
32
+
33
+ export const Routed: Story = {
34
+ decorators: [
35
+ (Story) => (
36
+ <RouterLinkProvider link={SoftRouterLink}>
37
+ <Story />
38
+ </RouterLinkProvider>
39
+ ),
40
+ ],
41
+ render: (linkProps) => (
42
+ <div className="flex gap-4">
43
+ <UnstyledLink {...linkProps}>
44
+ Routed jobs
45
+ </UnstyledLink>
46
+ <UnstyledLink
47
+ {...linkProps}
48
+ href="https://example.com/jobs"
49
+ >
50
+ External jobs
51
+ </UnstyledLink>
52
+ <UnstyledLink {...linkProps} href="#jobs">
53
+ Jobs fragment
54
+ </UnstyledLink>
55
+ </div>
56
+ ),
57
+ }
@@ -0,0 +1,40 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect } 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 "./UnstyledLink.stories.tsx"
8
+
9
+ const { Default, Routed } = composeStories(stories)
10
+
11
+ test("keeps the caller's paint and works without a router", async () => {
12
+ const { canvas } = await mountStory(Default)
13
+ const link = expectAgentDrivable(canvas, {
14
+ name: "Jobs",
15
+ role: "link",
16
+ })
17
+
18
+ await expect(link).toHaveAttribute("href", "/jobs")
19
+ await expect(link).toHaveAttribute(
20
+ "class",
21
+ "text-sm text-intent-accent-content hover:underline",
22
+ )
23
+ await expect(link).not.toHaveAttribute("data-router")
24
+ })
25
+
26
+ test("routes only destinations the injected router can serve", async () => {
27
+ const { canvas } = await mountStory(Routed)
28
+
29
+ await expect(
30
+ canvas.getByRole("link", { name: "Routed jobs" }),
31
+ ).toHaveAttribute("data-router", "soft")
32
+
33
+ await expect(
34
+ canvas.getByRole("link", { name: "External jobs" }),
35
+ ).not.toHaveAttribute("data-router")
36
+
37
+ await expect(
38
+ canvas.getByRole("link", { name: "Jobs fragment" }),
39
+ ).not.toHaveAttribute("data-router")
40
+ })
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { AnchorLink } from "./AnchorLink.tsx"
4
+ import { useRouterLink } from "./RouterLinkProvider.tsx"
5
+ import {
6
+ getIsRoutedHref,
7
+ type RouterLinkProps,
8
+ } from "./routerLink.ts"
9
+
10
+ export type UnstyledLinkProps = RouterLinkProps
11
+
12
+ /**
13
+ * A router-aware link that owns no paint.
14
+ *
15
+ * Use this only when the caller already owns the complete visual
16
+ * treatment. `TextLink` and `ButtonLink` remain the defaults for the
17
+ * two link appearances the library owns.
18
+ *
19
+ * An in-app destination goes through the injected router. Another
20
+ * origin, another scheme, or a same-page fragment stays a platform
21
+ * anchor. With no provider, the injected component is `AnchorLink`,
22
+ * so this remains a working `<a href>` in Storybook and tests.
23
+ */
24
+ export const UnstyledLink = ({
25
+ href,
26
+ ...linkProps
27
+ }: UnstyledLinkProps): ReactNode => {
28
+ const RouterLink = useRouterLink()
29
+ const LinkElement = getIsRoutedHref(href)
30
+ ? RouterLink
31
+ : AnchorLink
32
+
33
+ return <LinkElement {...linkProps} href={href} />
34
+ }
@@ -242,3 +242,60 @@ export const CATEGORICAL_CONTENT_CLASS: Record<
242
242
  9: "text-categorical-9-content",
243
243
  10: "text-categorical-10-content",
244
244
  }
245
+
246
+ /**
247
+ * The hover BORDER, which `CATEGORICAL_HOVER_CLASS` deliberately
248
+ * does not carry.
249
+ *
250
+ * That map answers "what does this surface do under the pointer",
251
+ * and a `Badge` has no border state to change. A tile does: it is a
252
+ * bordered card, and the whole point of colouring one is that the
253
+ * box reacts in its own hue rather than in the neutral
254
+ * `border-strong` every other box uses. Kept separate rather than
255
+ * added as a fifth appearance key, because an appearance is a claim
256
+ * about the fill and this is not one.
257
+ *
258
+ * Written out in full for the reason this file opens with.
259
+ */
260
+ export const CATEGORICAL_HOVER_BORDER_CLASS: Record<
261
+ CategoricalIndex,
262
+ string
263
+ > = {
264
+ 1: "hover:border-categorical-1-border",
265
+ 2: "hover:border-categorical-2-border",
266
+ 3: "hover:border-categorical-3-border",
267
+ 4: "hover:border-categorical-4-border",
268
+ 5: "hover:border-categorical-5-border",
269
+ 6: "hover:border-categorical-6-border",
270
+ 7: "hover:border-categorical-7-border",
271
+ 8: "hover:border-categorical-8-border",
272
+ 9: "hover:border-categorical-9-border",
273
+ 10: "hover:border-categorical-10-border",
274
+ }
275
+
276
+ /**
277
+ * A halo the width of a ring, in the hue's SURFACE.
278
+ *
279
+ * `surface` and not `border`: this sits directly against the solid
280
+ * fill it surrounds, so it is a soft step out of the colour rather
281
+ * than a second line around it. `PortraitTiles` draws it around a
282
+ * face, where a hard outline reads as a frame and this reads as
283
+ * light.
284
+ *
285
+ * Written out in full for the reason this file opens with.
286
+ */
287
+ export const CATEGORICAL_RING_CLASS: Record<
288
+ CategoricalIndex,
289
+ string
290
+ > = {
291
+ 1: "ring-categorical-1-surface",
292
+ 2: "ring-categorical-2-surface",
293
+ 3: "ring-categorical-3-surface",
294
+ 4: "ring-categorical-4-surface",
295
+ 5: "ring-categorical-5-surface",
296
+ 6: "ring-categorical-6-surface",
297
+ 7: "ring-categorical-7-surface",
298
+ 8: "ring-categorical-8-surface",
299
+ 9: "ring-categorical-9-surface",
300
+ 10: "ring-categorical-10-surface",
301
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
40
  export type {
41
41
  ActionTileItem,
42
+ ActionTilesAccent,
42
43
  ActionTilesProps,
43
44
  } from "./ActionTiles/ActionTiles.tsx"
44
45
  export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
@@ -129,7 +130,9 @@ export { copyText } from "./CopyButton/copyText.ts"
129
130
  export {
130
131
  CATEGORICAL_APPEARANCE_CLASS,
131
132
  CATEGORICAL_CONTENT_CLASS,
133
+ CATEGORICAL_HOVER_BORDER_CLASS,
132
134
  CATEGORICAL_HOVER_CLASS,
135
+ CATEGORICAL_RING_CLASS,
133
136
  CATEGORICAL_SOLID_FILL_CLASS,
134
137
  } from "./categoricalStyles.ts"
135
138
  export type { BadgeSize } from "./controlStyles.ts"
@@ -325,6 +328,12 @@ export type {
325
328
  export { Picker } from "./Picker/Picker.tsx"
326
329
  export type { PopoverProps } from "./Popover/Popover.tsx"
327
330
  export { Popover } from "./Popover/Popover.tsx"
331
+ export type {
332
+ PortraitTileItem,
333
+ PortraitTilesLayout,
334
+ PortraitTilesProps,
335
+ } from "./PortraitTiles/PortraitTiles.tsx"
336
+ export { PortraitTiles } from "./PortraitTiles/PortraitTiles.tsx"
328
337
  export type {
329
338
  ProgressBarProps,
330
339
  ProgressBarSize,
@@ -374,6 +383,8 @@ export type {
374
383
  RouterLinkProps,
375
384
  } from "./RouterLink/routerLink.ts"
376
385
  export { getIsRoutedHref } from "./RouterLink/routerLink.ts"
386
+ export type { UnstyledLinkProps } from "./RouterLink/UnstyledLink.tsx"
387
+ export { UnstyledLink } from "./RouterLink/UnstyledLink.tsx"
377
388
  export type { SearchInputProps } from "./SearchInput/SearchInput.tsx"
378
389
  export { SearchInput } from "./SearchInput/SearchInput.tsx"
379
390
  export type {
@@ -236,6 +236,14 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
236
236
  "Main",
237
237
  "MarkdownEditor",
238
238
  "MediaTile",
239
+ // `PortraitTiles` declares its container on the SET rather than
240
+ // on a tile, and that is the whole design: the question is not
241
+ // "how wide is this one face" but "does this picker have room to
242
+ // stack". A phone shows one full-width column of rows; a wall
243
+ // display shows a row of tall columns. Querying a tile would
244
+ // answer neither, because a track is about the same width in
245
+ // both.
246
+ "PortraitTiles",
239
247
  // `Stepper`'s container is the one that decides whether a
240
248
  // `horizontal` sequence is actually horizontal. Below `--cq-md`
241
249
  // it falls back to the vertical column, because four steps side
@@ -374,11 +382,10 @@ test("the barrel is the only place components are re-exported", async () => {
374
382
  // back-link and made `Button` get used for navigation: `TextLink`
375
383
  // and `ButtonLink`, both a real `<a href>`, differing in paint
376
384
  // rather than semantics: +2 -> 38, on top of `AdaptiveGrid`'s 36.
377
- // `AnchorLink`,
385
+ // `AnchorLink`, `UnstyledLink`,
378
386
  // `RouterLinkProvider`, `ReactRouterLink`, `ScrollMemoryProvider`
379
387
  // and `ReactRouterAdapter` are the router seam rather
380
- // than components anybody stories, and live in `RouterLink/`,
381
- // `Main/` and
388
+ // than painted components, and live in `RouterLink/`, `Main/` and
382
389
  // `reactRouter/` — outside this count by the `<Name>/<Name>.tsx`
383
390
  // rule, exactly as `Overlay/`'s parts are.
384
391
  // The unified app shell adds four: `Shell`, `Header`, `Rail`,
@@ -571,7 +578,13 @@ test("the barrel is the only place components are re-exported", async () => {
571
578
  // `isRange` is right there and wrong here for a reason that is
572
579
  // written down — a second text field is still a text field, while a
573
580
  // second thumb cannot share one `aria-valuenow`.
574
- expect(componentNames.length).toBe(64)
581
+ //
582
+ // `PortraitTiles` is +1 -> 65. It is the points-market kid picker:
583
+ // a face, a name and one number, which is a different question
584
+ // from `ActionTiles`' "what do you want to do" and would have
585
+ // meant one component whose icon is sometimes a 20px glyph beside
586
+ // a title and sometimes a 144px circle above it.
587
+ expect(componentNames.length).toBe(65)
575
588
 
576
589
  for (const name of componentNames) {
577
590
  expect(barrel).toContain(`export { ${name} }`)
package/src/tileStyles.ts CHANGED
@@ -38,7 +38,36 @@ export const TILE_PADDING_CLASS: Record<
38
38
  > = {
39
39
  sm: "px-3 py-2.5",
40
40
  md: "px-3.5 py-3",
41
- lg: "px-4 py-3.5",
41
+ // `lg` is the landing-page tile — mux-magic's "Pick a tool",
42
+ // Gallery Downloader's two cards — where the tile is the only
43
+ // thing on the page and reads as a panel rather than as a row.
44
+ // It was `px-4 py-3.5`, which is a control's padding wearing a
45
+ // card's name, and the owner called the result "very boring"
46
+ // when it shipped in QueuePilot.
47
+ lg: "px-6 py-5",
48
+ }
49
+
50
+ /**
51
+ * What a tile with an accent edge adds to its LEADING padding.
52
+ *
53
+ * The bar is drawn by `Card`'s accent-edge pseudo-element, which is
54
+ * an overlay rather than a border — so it takes no space in the box
55
+ * model and lands on top of the text unless the tile makes room for
56
+ * it. `Card` has the same arrangement and the same answer.
57
+ *
58
+ * A separate map rather than a second full padding scale, because
59
+ * only the leading side changes and the shared box is the promise
60
+ * the 2026-09-01 record made. It is applied ONLY when an edge is
61
+ * drawn: a radio tile has no bar, and giving it this padding would
62
+ * be dead space on the side its own control sits on.
63
+ */
64
+ export const TILE_ACCENT_EDGE_PADDING_CLASS: Record<
65
+ ControlSize,
66
+ string
67
+ > = {
68
+ sm: "ps-4",
69
+ md: "ps-4.5",
70
+ lg: "ps-7",
42
71
  }
43
72
 
44
73
  /**