@charcuterie/ui 4.20.1 → 4.22.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -6
  3. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +4 -1
  4. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -1
  5. package/dist/AdaptiveGrid/AdaptiveGrid.js +3 -1
  6. package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -1
  7. package/dist/AdaptiveGrid/chooseColumns.d.ts +10 -2
  8. package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -1
  9. package/dist/AdaptiveGrid/chooseColumns.js +10 -3
  10. package/dist/AdaptiveGrid/chooseColumns.js.map +1 -1
  11. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +6 -2
  12. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -1
  13. package/dist/AdaptiveGrid/useAdaptiveColumns.js +4 -1
  14. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -1
  15. package/dist/ComparisonChart/ComparisonChart.d.ts +12 -0
  16. package/dist/ComparisonChart/ComparisonChart.d.ts.map +1 -0
  17. package/dist/ComparisonChart/ComparisonChart.js +64 -0
  18. package/dist/ComparisonChart/ComparisonChart.js.map +1 -0
  19. package/dist/Lightbox/Lightbox.d.ts +10 -2
  20. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  21. package/dist/Lightbox/Lightbox.js +46 -3
  22. package/dist/Lightbox/Lightbox.js.map +1 -1
  23. package/dist/Overlay/useAnchoredOverlay.d.ts +2 -2
  24. package/dist/TableColumns/TableColumns.d.ts +14 -0
  25. package/dist/TableColumns/TableColumns.d.ts.map +1 -0
  26. package/dist/TableColumns/TableColumns.js +19 -0
  27. package/dist/TableColumns/TableColumns.js.map +1 -0
  28. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +4 -1
  29. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  30. package/dist/VirtualizedGrid/VirtualizedGrid.js +3 -1
  31. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  32. package/dist/index.d.ts +5 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -0
  35. package/dist/index.js.map +1 -1
  36. package/package.json +12 -9
  37. package/src/AdaptiveGrid/AdaptiveGrid.mdx +10 -0
  38. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +17 -0
  39. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +36 -2
  40. package/src/AdaptiveGrid/AdaptiveGrid.tsx +6 -0
  41. package/src/AdaptiveGrid/chooseColumns.ts +29 -1
  42. package/src/AdaptiveGrid/useAdaptiveColumns.ts +13 -1
  43. package/src/ComparisonChart/ComparisonChart.mdx +14 -0
  44. package/src/ComparisonChart/ComparisonChart.stories.tsx +85 -0
  45. package/src/ComparisonChart/ComparisonChart.test.tsx +33 -0
  46. package/src/ComparisonChart/ComparisonChart.tsx +161 -0
  47. package/src/CopyButton/CopyButton.test.tsx +25 -12
  48. package/src/Dialog/Dialog.test.tsx +38 -8
  49. package/src/LaneTimeline/LaneTimelineBar.tsx +3 -3
  50. package/src/Lightbox/Lightbox.mdx +9 -0
  51. package/src/Lightbox/Lightbox.stories.tsx +26 -0
  52. package/src/Lightbox/Lightbox.test.tsx +53 -0
  53. package/src/Lightbox/Lightbox.tsx +109 -5
  54. package/src/PortraitTiles/PortraitTiles.test.tsx +17 -11
  55. package/src/TableColumns/TableColumns.mdx +14 -0
  56. package/src/TableColumns/TableColumns.stories.tsx +81 -0
  57. package/src/TableColumns/TableColumns.test.tsx +35 -0
  58. package/src/TableColumns/TableColumns.tsx +93 -0
  59. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +21 -8
  60. package/src/VirtualizedGrid/VirtualizedGrid.tsx +6 -0
  61. package/src/board.storyHelpers.tsx +7 -1
  62. package/src/htmlEditor/HtmlEditor.test.tsx +32 -2
  63. package/src/index.ts +11 -1
  64. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +28 -10
  65. package/src/mdxReferences.test.ts +1 -1
  66. package/src/sourceRules.test.ts +2 -1
  67. package/src/vitestProvidedContext.d.ts +6 -0
@@ -0,0 +1,81 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import { useState } from "react"
4
+ import { ContainerBoard } from "../board.storyHelpers.tsx"
5
+ import { TableColumns } from "./TableColumns.tsx"
6
+
7
+ const meta = {
8
+ title: "Components/Data/TableColumns",
9
+ component: TableColumns,
10
+ args: {
11
+ columns: [
12
+ {
13
+ key: "name",
14
+ label: "Name",
15
+ isVisible: true,
16
+ isRequired: true,
17
+ },
18
+ {
19
+ key: "duration",
20
+ label: "Duration",
21
+ isVisible: true,
22
+ },
23
+ { key: "median", label: "Median", isVisible: false },
24
+ ],
25
+ onChange: () => {},
26
+ },
27
+ } satisfies Meta<typeof TableColumns>
28
+ export default meta
29
+ type Story = StoryObj<typeof meta>
30
+ const Controlled = (
31
+ props: React.ComponentProps<typeof TableColumns>,
32
+ ) => {
33
+ const [columns, setColumns] = useState([...props.columns])
34
+ return (
35
+ <>
36
+ <TableColumns
37
+ {...props}
38
+ columns={columns}
39
+ onChange={setColumns}
40
+ />
41
+ <p>
42
+ {columns
43
+ .filter((column) => column.isVisible)
44
+ .map((column) => column.label)
45
+ .join(", ")}
46
+ </p>
47
+ </>
48
+ )
49
+ }
50
+ export const Playground: Story = {
51
+ parameters: playgroundParameters,
52
+ render: (props) => <Controlled {...props} />,
53
+ }
54
+ export const AllVariants: Story = {
55
+ render: (props) => (
56
+ <Controlled {...props} label="Choose columns" />
57
+ ),
58
+ }
59
+ export const AllStates: Story = {
60
+ args: {
61
+ columns: [
62
+ {
63
+ key: "name",
64
+ label: "Name",
65
+ isVisible: true,
66
+ isRequired: true,
67
+ },
68
+ ],
69
+ },
70
+ render: (props) => <Controlled {...props} />,
71
+ }
72
+ export const Responsive: Story = {
73
+ render: (props) => (
74
+ <ContainerBoard>
75
+ {() => <Controlled {...props} />}
76
+ </ContainerBoard>
77
+ ),
78
+ }
79
+ export const Interactive: Story = {
80
+ render: (props) => <Controlled {...props} />,
81
+ }
@@ -0,0 +1,35 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+ import { mountStory } from "../mountStory.testHelpers.ts"
5
+ import { expectAgentDrivable } from "../testing/index.ts"
6
+ import * as stories from "./TableColumns.stories.tsx"
7
+
8
+ const { Interactive } = composeStories(stories)
9
+ test("column visibility and order can be changed with named controls", async () => {
10
+ const { canvas, body } = await mountStory(Interactive)
11
+ await userEvent.click(
12
+ expectAgentDrivable(canvas, {
13
+ role: "button",
14
+ name: "Columns",
15
+ }),
16
+ )
17
+ await userEvent.click(
18
+ expectAgentDrivable(body, {
19
+ role: "checkbox",
20
+ name: "Median",
21
+ }),
22
+ )
23
+ await expect(
24
+ canvas.getByText("Name, Duration, Median"),
25
+ ).toBeVisible()
26
+ await userEvent.click(
27
+ expectAgentDrivable(body, {
28
+ role: "button",
29
+ name: "Move Median earlier",
30
+ }),
31
+ )
32
+ await expect(
33
+ canvas.getByText("Name, Median, Duration"),
34
+ ).toBeVisible()
35
+ })
@@ -0,0 +1,93 @@
1
+ import { useState } from "react"
2
+ import { Button } from "../Button/Button.tsx"
3
+ import { Checkbox } from "../Checkbox/Checkbox.tsx"
4
+ import { Popover } from "../Popover/Popover.tsx"
5
+ import { ReorderList } from "../ReorderList/ReorderList.tsx"
6
+
7
+ export type TableColumnChoice = {
8
+ key: string
9
+ label: string
10
+ isVisible: boolean
11
+ isRequired?: boolean
12
+ }
13
+ export type TableColumnsProps = {
14
+ columns: readonly TableColumnChoice[]
15
+ onChange: (columns: TableColumnChoice[]) => void
16
+ label?: string
17
+ }
18
+
19
+ /** Column visibility and ordering, with keyboard moves as the primary path. */
20
+ export const TableColumns = ({
21
+ columns,
22
+ onChange,
23
+ label = "Columns",
24
+ }: TableColumnsProps) => {
25
+ const [isVisible, setIsVisible] = useState(false)
26
+ return (
27
+ <Popover
28
+ heading="Table columns"
29
+ isVisible={isVisible}
30
+ onDismiss={() => setIsVisible(false)}
31
+ trigger={
32
+ <Button
33
+ intent="neutral"
34
+ appearance="soft"
35
+ onClick={() => setIsVisible(!isVisible)}
36
+ >
37
+ {label}
38
+ </Button>
39
+ }
40
+ >
41
+ <ReorderList
42
+ label="Column order"
43
+ items={columns}
44
+ onReorder={(from, to) => {
45
+ const next = [...columns]
46
+ const [column] = next.splice(from, 1)
47
+ next.splice(to, 0, column)
48
+ onChange(next)
49
+ }}
50
+ renderItem={({ item, isFirst, isLast, moveBy }) => (
51
+ <div className="flex flex-wrap items-center justify-between gap-2 py-1">
52
+ <Checkbox
53
+ label={item.label}
54
+ isChecked={item.isVisible}
55
+ isDisabled={item.isRequired}
56
+ onChange={(isChecked) =>
57
+ onChange(
58
+ columns.map((column) =>
59
+ column.key === item.key
60
+ ? { ...column, isVisible: isChecked }
61
+ : column,
62
+ ),
63
+ )
64
+ }
65
+ />
66
+ <div className="flex gap-1">
67
+ <Button
68
+ size="sm"
69
+ intent="neutral"
70
+ appearance="ghost"
71
+ aria-label={`Move ${item.label} earlier`}
72
+ isDisabled={isFirst}
73
+ onClick={() => moveBy(-1)}
74
+ >
75
+ Earlier
76
+ </Button>
77
+ <Button
78
+ size="sm"
79
+ intent="neutral"
80
+ appearance="ghost"
81
+ aria-label={`Move ${item.label} later`}
82
+ isDisabled={isLast}
83
+ onClick={() => moveBy(1)}
84
+ >
85
+ Later
86
+ </Button>
87
+ </div>
88
+ </div>
89
+ )}
90
+ />
91
+ </Popover>
92
+ )
93
+ }
@@ -308,15 +308,28 @@ test("a grid inside a hidden panel mounts nothing and reveals without scrolling"
308
308
  // Wait for the revealed grid to have MEASURED, not merely to
309
309
  // have mounted — the adjustment, when it happens, happens on the
310
310
  // virtualizer's resize path.
311
+ //
312
+ // Two signals, because one of them depends on the window. The end
313
+ // padding stands in for rows below the window, so it is only ever
314
+ // positive when the list is longer than the window. In a 3440x1440
315
+ // window all 48 cards fit, the padding is rightly 0, and waiting for
316
+ // it timed out on a grid that was working. There the signal is the
317
+ // LAST card drawn with a real height.
311
318
  await waitFor(async () => {
312
- await expect(
313
- Number(
314
- getShownList().style.paddingBlockEnd.replace(
315
- "px",
316
- "",
317
- ),
318
- ),
319
- ).toBeGreaterThan(0)
319
+ const list = getShownList()
320
+
321
+ const isPadded =
322
+ Number(list.style.paddingBlockEnd.replace("px", "")) >
323
+ 0
324
+
325
+ const lastCell = list.querySelector<HTMLElement>(
326
+ `li[aria-posinset="${list.querySelector("li")?.getAttribute("aria-setsize")}"]`,
327
+ )
328
+
329
+ const isEveryCardDrawn =
330
+ (lastCell?.getBoundingClientRect().height ?? 0) > 0
331
+
332
+ await expect(isPadded || isEveryCardDrawn).toBe(true)
320
333
  })
321
334
 
322
335
  // The symptom, kept as a guard rather than as the proof. It is
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react"
2
2
  import { useLayoutEffect, useState } from "react"
3
3
 
4
+ import type { GridLayout } from "../AdaptiveGrid/chooseColumns.ts"
4
5
  import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
5
6
  import { useAdaptiveColumns } from "../AdaptiveGrid/useAdaptiveColumns.ts"
6
7
  import { toClassName } from "../toClassName.ts"
@@ -62,6 +63,8 @@ export type VirtualizedGridProps<Item> = {
62
63
  * is one whose length a reader cannot count for itself.
63
64
  */
64
65
  label?: string
66
+ /** Opt in to width-first media layout. */
67
+ layout?: GridLayout
65
68
  /** The most columns the automatic answer will take. */
66
69
  maxColumns?: number
67
70
  /** The narrowest a column may be, in CSS px. */
@@ -203,6 +206,7 @@ export const VirtualizedGrid = <Item,>({
203
206
  itemBlockSize,
204
207
  items,
205
208
  label,
209
+ layout: gridLayout,
206
210
  maxColumns,
207
211
  minColumnInlineSize,
208
212
  overscanRows = 4,
@@ -214,6 +218,8 @@ export const VirtualizedGrid = <Item,>({
214
218
  chromeBlockSize,
215
219
  itemBlockSize,
216
220
  itemCount: items.length,
221
+ gap,
222
+ layout: gridLayout,
217
223
  maxAutoColumns: maxColumns,
218
224
  minColumnInlineSize,
219
225
  storageKey,
@@ -133,7 +133,13 @@ export const ContainerBoard = ({
133
133
  },
134
134
  ].map((panel) => (
135
135
  <div
136
- className="flex flex-col gap-2"
136
+ // `shrink-0`, or the promise above is false: a flex item
137
+ // shrinks to its line, so in a window narrower than 34rem the
138
+ // widest panel quietly became the window's width and every
139
+ // "container, not window" test measured the window after all.
140
+ // A board wider than the window scrolls, which is the honest
141
+ // picture of three fixed containers on a phone.
142
+ className="flex shrink-0 flex-col gap-2"
137
143
  key={panel.label}
138
144
  style={{ inlineSize: panel.inlineSize }}
139
145
  >
@@ -212,6 +212,35 @@ test("external defaults never overwrite the document; real edits emit HTML and k
212
212
  ).not.toHaveTextContent("data-old-format")
213
213
  })
214
214
 
215
+ /**
216
+ * A toolbar action wherever the toolbar put it. On the phone the whole
217
+ * row folds into the overflow panel, so a test that looked for "Bold"
218
+ * in the row asserted about the window rather than the editor — and a
219
+ * read-only editor must refuse formatting from the panel too.
220
+ */
221
+ const findToolbarAction = async (
222
+ toolbar: HTMLElement,
223
+ name: string,
224
+ ) => {
225
+ const inRow = within(toolbar).queryByRole("button", {
226
+ name,
227
+ })
228
+
229
+ if (inRow) {
230
+ return inRow
231
+ }
232
+
233
+ await userEvent.click(
234
+ within(toolbar).getByRole("button", {
235
+ name: "More actions",
236
+ }),
237
+ )
238
+
239
+ return within(
240
+ await within(document.body).findByRole("dialog"),
241
+ ).getByRole("button", { name })
242
+ }
243
+
215
244
  test("read-only and disabled editors refuse typing and formatting", async () => {
216
245
  const { canvas } = await mountStory(AllStates)
217
246
  const readOnly = await canvas.findByRole("textbox", {
@@ -234,11 +263,12 @@ test("read-only and disabled editors refuse typing and formatting", async () =>
234
263
  )
235
264
  await expect(disabled).toHaveAttribute("tabindex", "-1")
236
265
  await expect(
237
- within(
266
+ await findToolbarAction(
238
267
  canvas.getByRole("toolbar", {
239
268
  name: "Read-only note formatting",
240
269
  }),
241
- ).getByRole("button", { name: "Bold" }),
270
+ "Bold",
271
+ ),
242
272
  ).toBeDisabled()
243
273
  const required = canvas.getByRole("textbox", {
244
274
  name: "Required notes",
package/src/index.ts CHANGED
@@ -130,6 +130,8 @@ export type {
130
130
  export { ColorSchemeToggle } from "./ColorSchemeToggle/ColorSchemeToggle.tsx"
131
131
  export type { ComboboxProps } from "./Combobox/Combobox.tsx"
132
132
  export { Combobox } from "./Combobox/Combobox.tsx"
133
+ export type { ComparisonChartProps } from "./ComparisonChart/ComparisonChart.tsx"
134
+ export { ComparisonChart } from "./ComparisonChart/ComparisonChart.tsx"
133
135
  export type {
134
136
  CopyButtonProps,
135
137
  CopyStatus,
@@ -260,7 +262,10 @@ export {
260
262
  toBarPlacement,
261
263
  toTimelineAxis,
262
264
  } from "./LaneTimeline/laneTimelineGeometry.ts"
263
- export type { LightboxProps } from "./Lightbox/Lightbox.tsx"
265
+ export type {
266
+ LightboxImage,
267
+ LightboxProps,
268
+ } from "./Lightbox/Lightbox.tsx"
264
269
  export { Lightbox } from "./Lightbox/Lightbox.tsx"
265
270
  export type {
266
271
  ListboxItem,
@@ -496,6 +501,11 @@ export {
496
501
  getConnectionLabel,
497
502
  getIsConnectionBusy,
498
503
  } from "./statusIntent.ts"
504
+ export type {
505
+ TableColumnChoice,
506
+ TableColumnsProps,
507
+ } from "./TableColumns/TableColumns.tsx"
508
+ export { TableColumns } from "./TableColumns/TableColumns.tsx"
499
509
  export type { TableViewportProps } from "./TableViewport/TableViewport.tsx"
500
510
  export { TableViewport } from "./TableViewport/TableViewport.tsx"
501
511
  export type {
@@ -59,14 +59,17 @@ test("it is an article whose headings are headings", async () => {
59
59
  "false",
60
60
  )
61
61
 
62
+ // `findBy`, not `getBy`: CodeMirror draws its decorations after the
63
+ // first paint, and with four browser windows sharing the runner the
64
+ // second heading was not drawn yet when a synchronous query looked.
62
65
  await expect(
63
- canvas.getByRole("heading", {
66
+ await canvas.findByRole("heading", {
64
67
  name: "Rack move, phase two",
65
68
  }),
66
69
  ).toHaveAttribute("aria-level", "1")
67
70
 
68
71
  await expect(
69
- canvas.getByRole("heading", {
72
+ await canvas.findByRole("heading", {
70
73
  name: "Before the window",
71
74
  }),
72
75
  ).toHaveAttribute("aria-level", "2")
@@ -202,7 +205,7 @@ test("a link is a real anchor in the tab order", async () => {
202
205
  test("a checkbox is inert until a consumer says where the tick goes", async () => {
203
206
  const { canvas } = await mountStory(AllStates)
204
207
 
205
- const inert = canvas.getByRole("checkbox", {
208
+ const inert = await canvas.findByRole("checkbox", {
206
209
  name: "Photograph the current cabling",
207
210
  })
208
211
 
@@ -239,9 +242,11 @@ test("hostile markdown renders as characters, not as behaviour", async () => {
239
242
  const { canvas, canvasElement } =
240
243
  await mountStory(HostileMarkdown)
241
244
 
242
- const hrefs = canvas
243
- .getAllByRole("link")
244
- .map((link: HTMLElement) => link.getAttribute("href"))
245
+ // `findAllBy`: the links are CodeMirror decorations, drawn after the
246
+ // first paint — the same race as the headings test above.
247
+ const hrefs = (await canvas.findAllByRole("link")).map(
248
+ (link: HTMLElement) => link.getAttribute("href"),
249
+ )
245
250
 
246
251
  await expect(hrefs).toEqual([
247
252
  "https://example.invalid/runbook",
@@ -336,7 +341,7 @@ test("inline markup in link text does not drop the document to raw source", asyn
336
341
  // The canary. This heading has nothing to do with links, and it
337
342
  // is what stopped rendering when the plugin died.
338
343
  await expect(
339
- canvas.getByRole("heading", {
344
+ await canvas.findByRole("heading", {
340
345
  name: "Where the fix landed",
341
346
  }),
342
347
  ).toHaveAttribute("aria-level", "2")
@@ -344,11 +349,16 @@ test("inline markup in link text does not drop the document to raw source", asyn
344
349
  // No raw source anywhere in the document.
345
350
  const article = canvas.getByRole("article")
346
351
 
347
- await expect(article.textContent).not.toContain("](")
352
+ // The heading can paint before lower link decorations finish parsing.
353
+ // Retry the complete contract rather than treating that partial paint
354
+ // as the final document; all three source-marker assertions still apply.
355
+ await waitFor(async () => {
356
+ await expect(article.textContent).not.toContain("](")
348
357
 
349
- await expect(article.textContent).not.toContain("**")
358
+ await expect(article.textContent).not.toContain("**")
350
359
 
351
- await expect(article.textContent).not.toContain("~~")
360
+ await expect(article.textContent).not.toContain("~~")
361
+ })
352
362
  })
353
363
 
354
364
  /**
@@ -415,6 +425,14 @@ test("a code span is smaller than the prose it sits in", async () => {
415
425
 
416
426
  const article = canvas.getByRole("article")
417
427
 
428
+ // CodeMirror can finish its syntax decorations after the story mounts.
429
+ // Wait for the measured span itself rather than capturing a stale null.
430
+ await waitFor(() => {
431
+ expect(
432
+ article.querySelector(".cm-md-code"),
433
+ ).not.toBeNull()
434
+ })
435
+
418
436
  const code = article.querySelector(".cm-md-code")
419
437
 
420
438
  await expect(code).not.toBeNull()
@@ -6,7 +6,7 @@
6
6
  * and does not fail `vitest` — MDX resolves `of` at *runtime*, so a
7
7
  * renamed story turns into `of={undefined}` and the docs page
8
8
  * renders Storybook's "component failed to render" panel instead of
9
- * the page. `yarn smoke:storybook` catches it, which is good, but
9
+ * the page. `pnpm smoke:storybook` catches it, which is good, but
10
10
  * only after a full Storybook build; this catches it in under a
11
11
  * second, which is the difference between noticing during the edit
12
12
  * and noticing in CI.
@@ -622,7 +622,8 @@ test("the barrel is the only place components are re-exported", async () => {
622
622
  // Chart shares portable SVG geometry with Preact and image consumers.
623
623
  // TableViewport owns readable comparisons and full-window expansion.
624
624
  // MetricCard shares prominent values and category-coloured icons through Card.
625
- expect(componentNames.length).toBe(75)
625
+ // ComparisonChart and TableColumns share compact comparisons and column controls.
626
+ expect(componentNames.length).toBe(77)
626
627
 
627
628
  for (const name of componentNames) {
628
629
  expect(barrel).toContain(`export { ${name} }`)
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Puts `inject("viewport")`'s type in front of every test in this
3
+ * package. The augmentation itself lives beside the viewport list in
4
+ * `@charcuterie/vitest-config`, so the four names are declared once.
5
+ */
6
+ import "@charcuterie/vitest-config/viewports.js"