@charcuterie/ui 3.4.1 → 3.6.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.
@@ -0,0 +1,224 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+ import { Button } from "../Button/Button.tsx"
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import { Card } from "../Card/Card.tsx"
11
+ import type { Step } from "./Stepper.tsx"
12
+ import { Stepper } from "./Stepper.tsx"
13
+
14
+ const RIP: Step[] = [
15
+ {
16
+ description: "3 of 3 discs read",
17
+ key: "rip",
18
+ label: "Rip",
19
+ status: "done",
20
+ },
21
+ {
22
+ description: "2 of 5 titles tagged",
23
+ key: "tag",
24
+ label: "Tag",
25
+ status: "current",
26
+ },
27
+ {
28
+ description: "Opens when Tag is finished",
29
+ key: "file",
30
+ label: "File",
31
+ status: "upcoming",
32
+ },
33
+ ]
34
+
35
+ const meta = {
36
+ title: "Components/Data/Stepper",
37
+ component: Stepper,
38
+ parameters: { layout: "padded" },
39
+ args: {
40
+ headingLevel: 3,
41
+ label: "Ingest sequence",
42
+ orientation: "vertical",
43
+ steps: RIP,
44
+ },
45
+ } satisfies Meta<typeof Stepper>
46
+
47
+ export default meta
48
+
49
+ type Story = StoryObj<typeof meta>
50
+
51
+ /**
52
+ * The marker is the ordinal and never a tick — this library ships
53
+ * no icons and no symbol glyphs, and a `✓` the font lacks renders
54
+ * as an empty box in the one position where an empty box reads as
55
+ * an error.
56
+ */
57
+ export const Default: Story = {}
58
+
59
+ export const AllVariants: Story = {
60
+ render: (stepperProps) => (
61
+ <StorySection title="Four statuses, two orientations, and the case the component exists for: steps that CARRY something.">
62
+ <StoryGrid columns={2}>
63
+ <StoryCell align="stretch" label="vertical">
64
+ <Stepper {...stepperProps} />
65
+ </StoryCell>
66
+
67
+ <StoryCell
68
+ align="stretch"
69
+ label="orientation=horizontal"
70
+ >
71
+ <Stepper
72
+ {...stepperProps}
73
+ label="Ingest sequence, across"
74
+ orientation="horizontal"
75
+ />
76
+ </StoryCell>
77
+
78
+ <StoryCell align="stretch" label="status=blocked">
79
+ {/* Blocked is not a worse `upcoming`. Its turn HAS come
80
+ and something is in the way — a thing to go and fix
81
+ rather than a thing to wait for. */}
82
+ <Stepper
83
+ {...stepperProps}
84
+ label="A sequence held up"
85
+ steps={[
86
+ { key: "a", label: "Rip", status: "done" },
87
+ {
88
+ description:
89
+ "Waiting on Rip, which has no titles yet — add one, or delete the step",
90
+ key: "b",
91
+ label: "Tag",
92
+ status: "blocked",
93
+ },
94
+ {
95
+ key: "c",
96
+ label: "File",
97
+ status: "upcoming",
98
+ },
99
+ ]}
100
+ />
101
+ </StoryCell>
102
+
103
+ <StoryCell
104
+ align="stretch"
105
+ label="steps that carry content"
106
+ >
107
+ <Stepper
108
+ {...stepperProps}
109
+ label="A sequence with its work under it"
110
+ steps={[
111
+ {
112
+ content: (
113
+ <Card>Read the disc into an ISO</Card>
114
+ ),
115
+ key: "a",
116
+ label: "Rip",
117
+ status: "done",
118
+ },
119
+ {
120
+ content: <Card>Match each title</Card>,
121
+ key: "b",
122
+ label: "Tag",
123
+ status: "current",
124
+ },
125
+ ]}
126
+ />
127
+ </StoryCell>
128
+ </StoryGrid>
129
+ </StorySection>
130
+ ),
131
+ }
132
+
133
+ export const AllStates: Story = {
134
+ render: (stepperProps) => (
135
+ <StorySection title="Every status beside every other, so the four are legible as a set rather than one at a time.">
136
+ <Stepper
137
+ {...stepperProps}
138
+ label="Every status"
139
+ steps={[
140
+ { key: "a", label: "Done", status: "done" },
141
+ {
142
+ key: "b",
143
+ label: "In progress",
144
+ status: "current",
145
+ },
146
+ { key: "c", label: "Blocked", status: "blocked" },
147
+ {
148
+ key: "d",
149
+ label: "Not started",
150
+ status: "upcoming",
151
+ },
152
+ ]}
153
+ />
154
+ </StorySection>
155
+ ),
156
+ }
157
+
158
+ /**
159
+ * `horizontal` is a REQUEST. Below `cq-md` it falls back to the
160
+ * column, because four steps side by side in a narrow container
161
+ * squash into `2. / De- / dupe` — measured, in the app this
162
+ * component came out of.
163
+ */
164
+ export const Responsive: Story = {
165
+ args: { orientation: "horizontal" },
166
+ render: (stepperProps) => (
167
+ <ContainerBoard>
168
+ <Stepper {...stepperProps} />
169
+ </ContainerBoard>
170
+ ),
171
+ }
172
+
173
+ /**
174
+ * The whole keyboard path, which is the point of the headings: a
175
+ * stepper is a document outline, not a widget. There is nothing to
176
+ * focus and nothing to arrow between — advancing is the app's
177
+ * operation, and the component only reports where the sequence got
178
+ * to.
179
+ */
180
+ export const Interactive: Story = {
181
+ render: (stepperProps) => {
182
+ const [reached, setReached] = useState(1)
183
+
184
+ return (
185
+ <div className="flex flex-col gap-3">
186
+ <div className="flex gap-2">
187
+ <Button
188
+ isDisabled={reached === 0}
189
+ onClick={() => {
190
+ setReached((was) => was - 1)
191
+ }}
192
+ type="button"
193
+ >
194
+ Back
195
+ </Button>
196
+ <Button
197
+ intent="accent"
198
+ isDisabled={reached === RIP.length}
199
+ onClick={() => {
200
+ setReached((was) => was + 1)
201
+ }}
202
+ type="button"
203
+ >
204
+ Advance
205
+ </Button>
206
+ </div>
207
+
208
+ <Stepper
209
+ {...stepperProps}
210
+ steps={RIP.map((step, index) => ({
211
+ ...step,
212
+ description: undefined,
213
+ status:
214
+ index < reached
215
+ ? ("done" as const)
216
+ : index === reached
217
+ ? ("current" as const)
218
+ : ("upcoming" as const),
219
+ }))}
220
+ />
221
+ </div>
222
+ )
223
+ },
224
+ }
@@ -0,0 +1,112 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, 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 * as stories from "./Stepper.stories.tsx"
8
+
9
+ const { AllStates, Default, Interactive, Responsive } =
10
+ composeStories(stories)
11
+
12
+ test("the sequence is an ordered list with a name", async () => {
13
+ const { canvas } = await mountStory(Default)
14
+
15
+ // A `<ol>`, not a stack of divs: the ORDER is the meaning, and a
16
+ // screen reader announcing "list, 3 items" is what carries it.
17
+ await expect(
18
+ canvas.getByRole("list", { name: "Ingest sequence" }),
19
+ ).toBeVisible()
20
+ await expect(
21
+ canvas.getAllByRole("listitem"),
22
+ ).toHaveLength(3)
23
+ })
24
+
25
+ test("each step is a heading at the level the caller asked for", async () => {
26
+ const { canvas } = await mountStory(Default)
27
+
28
+ await expect(
29
+ canvas.getByRole("heading", { level: 3, name: /Rip/ }),
30
+ ).toBeVisible()
31
+ })
32
+
33
+ /**
34
+ * The rule this component turns on. Four statuses painted as four
35
+ * marker colours is a WCAG 1.4.1 failure however clear it looks,
36
+ * so every status reaches a screen reader as a word — and that is
37
+ * also the only channel that survives greyscale and the ePaper
38
+ * build.
39
+ */
40
+ test("every status is a word, not only a colour", async () => {
41
+ const { canvas } = await mountStory(AllStates)
42
+
43
+ for (const status of [
44
+ "Done",
45
+ "In progress",
46
+ "Blocked",
47
+ "Not started",
48
+ ]) {
49
+ // Twice each: once as the step's visible label in this story,
50
+ // once as the visually-hidden status.
51
+ await expect(
52
+ canvas.getAllByText(status).length,
53
+ ).toBeGreaterThanOrEqual(2)
54
+ }
55
+ })
56
+
57
+ test("the marker is the ordinal, so it renders in a font with no tick glyph", async () => {
58
+ const { canvas } = await mountStory(Default)
59
+
60
+ // Not `✓`. `docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-
61
+ // glyphs.md` — a glyph the font lacks paints as an empty box, and
62
+ // an empty box in the "done" position reads as an error.
63
+ await expect(canvas.getByText("1")).toBeVisible()
64
+ await expect(canvas.getByText("3")).toBeVisible()
65
+ })
66
+
67
+ /**
68
+ * The last step must not draw a connector into empty space. A real
69
+ * element rather than a `::before` is what makes this assertable at
70
+ * all — a pseudo-element has nothing to count.
71
+ */
72
+ test("the last step draws no connector", async () => {
73
+ const { canvas } = await mountStory(Default)
74
+
75
+ const connectors = canvas
76
+ .getAllByRole("listitem")
77
+ .map((item: HTMLElement) =>
78
+ item.querySelector("span[aria-hidden='true']"),
79
+ )
80
+
81
+ await expect(connectors.filter(Boolean)).toHaveLength(2)
82
+ await expect(connectors[2]).toBeNull()
83
+ })
84
+
85
+ test("advancing moves which step is current", async () => {
86
+ const { canvas } = await mountStory(Interactive)
87
+
88
+ const heading = () =>
89
+ canvas.getByRole("heading", { level: 3, name: /Tag/ })
90
+
91
+ await expect(heading()).toBeVisible()
92
+
93
+ await userEvent.click(
94
+ canvas.getByRole("button", { name: "Advance" }),
95
+ )
96
+
97
+ // Tag was current and is now done — the status word is the
98
+ // assertion, because it is the only channel that is not a colour.
99
+ await expect(
100
+ heading().textContent?.includes("Done"),
101
+ ).toBe(true)
102
+ })
103
+
104
+ test("no axe violations, in either orientation", async () => {
105
+ const vertical = await mountStory(Default)
106
+
107
+ await expectNoAxeViolations(vertical.canvasElement)
108
+
109
+ const horizontal = await mountStory(Responsive)
110
+
111
+ await expectNoAxeViolations(horizontal.canvasElement)
112
+ })
@@ -0,0 +1,246 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { toClassName } from "../toClassName.ts"
4
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
5
+
6
+ /**
7
+ * Where a step sits in the sequence.
8
+ *
9
+ * `blocked` is not a worse `upcoming`. Upcoming means "its turn has
10
+ * not come"; blocked means "its turn has come and something is in
11
+ * the way", which is a thing to go and fix rather than a thing to
12
+ * wait for. Docket's phases need exactly this split — a phase gated
13
+ * behind an unfinished one, versus a phase gated behind an EMPTY
14
+ * one, where the second is a mistake somebody has to correct.
15
+ */
16
+ export type StepStatus =
17
+ | "blocked"
18
+ | "current"
19
+ | "done"
20
+ | "upcoming"
21
+
22
+ export type Step = {
23
+ /** What the marker shows instead of its ordinal position. */
24
+ readonly marker?: ReactNode
25
+ readonly key: string
26
+ readonly label: ReactNode
27
+ /** One line under the label — why it is blocked, what it is
28
+ * waiting on, when it finished. */
29
+ readonly description?: ReactNode
30
+ /**
31
+ * What BELONGS to this step: the tasks in a phase, the fields in
32
+ * a form stage, the checks in a pipeline.
33
+ *
34
+ * The reason this component is not a `ProgressBar` with a legend.
35
+ * A bar says a sequence is half done; only the contents say which
36
+ * things go together and which one is next.
37
+ */
38
+ readonly content?: ReactNode
39
+ readonly status?: StepStatus
40
+ }
41
+
42
+ export type StepperOrientation = "horizontal" | "vertical"
43
+
44
+ export type StepperProps = {
45
+ readonly className?: string
46
+ /**
47
+ * What level the step labels are. The caller's, because only the
48
+ * caller knows what the stepper sits under — one below a page
49
+ * `<h1>` wants `2`, one inside an already-deep section wants `4`.
50
+ * Getting it wrong is an axe `heading-order` failure and a broken
51
+ * screen-reader outline.
52
+ */
53
+ readonly headingLevel?: 2 | 3 | 4 | 5
54
+ /** The list's accessible name. Required — an unnamed sequence of
55
+ * numbers is a sequence of numbers. */
56
+ readonly label: string
57
+ /**
58
+ * `horizontal` is a REQUEST, not a promise: it collapses to
59
+ * vertical below `cq-md`, because four steps side by side in a
60
+ * narrow container squash into unreadable slivers (`2. / De- /
61
+ * dupe`, measured). A step carrying `content` should stay
62
+ * vertical — a column cannot hold a list.
63
+ */
64
+ readonly orientation?: StepperOrientation
65
+ readonly steps: readonly Step[]
66
+ }
67
+
68
+ /**
69
+ * An ordered sequence of steps, with what belongs to each one.
70
+ *
71
+ * ### Why the status is never colour alone
72
+ *
73
+ * Four states painted only as four marker colours is a WCAG 1.4.1
74
+ * failure however clear it looks, so each status reaches a screen
75
+ * reader as a word — `VisuallyHidden`, next to the label — and gets
76
+ * a second visual channel besides hue: a **filled** marker for done
77
+ * and current, an **outlined** one for upcoming and blocked, and a
78
+ * connector that is solid behind finished work and dashed ahead of
79
+ * it.
80
+ *
81
+ * ### Why the marker is a number and never a tick
82
+ *
83
+ * This library
84
+ * [ships no icons and no symbol glyphs](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md).
85
+ * A `✓` renders as an empty box wherever the font lacks it — which
86
+ * includes the sandbox chromium, the kiosk image and the ePaper
87
+ * build — and an empty box in the "done" position reads as an error.
88
+ * The ordinal always renders. An app that owns a glyph set can pass
89
+ * one as `marker`.
90
+ *
91
+ * ### Why the connector is an element
92
+ *
93
+ * Not a `::before`. `styles.css` is reserved for the handful of
94
+ * visuals a Tailwind utility genuinely cannot express — the looping
95
+ * animations and the scrollbar — and a line between two markers is
96
+ * not one of them. A real `<span aria-hidden>` also stops the last
97
+ * step drawing a connector into empty space, which is a
98
+ * `:not(:last-child)` selector nobody remembers to write.
99
+ */
100
+ export const Stepper = ({
101
+ className,
102
+ headingLevel = 3,
103
+ label,
104
+ orientation = "vertical",
105
+ steps,
106
+ }: StepperProps) => {
107
+ const Heading = `h${headingLevel}` as "h3"
108
+ const isHorizontal = orientation === "horizontal"
109
+
110
+ return (
111
+ // The container is a WRAPPER, never the `<ol>` itself.
112
+ //
113
+ // An element cannot answer its own container query: declaring
114
+ // `container-type` on the list makes it the query container for
115
+ // its DESCENDANTS, and `cq-md:` on the list resolves against
116
+ // whatever container is further up — usually none. The first
117
+ // version of this component put both on the `<ol>`, and the
118
+ // result was a horizontal stepper whose CONNECTORS went
119
+ // horizontal (they are children, so they matched) while the
120
+ // list stayed a column. Valid CSS, no error, nothing for axe or
121
+ // a DOM test to say, and obvious the moment it was rendered.
122
+ // `Board` carries the same note for the same reason.
123
+ <div className={toClassName("@container", className)}>
124
+ <ol
125
+ aria-label={label}
126
+ className={toClassName(
127
+ "flex list-none flex-col gap-5 p-0",
128
+ // Horizontal is the WIDE answer only. Below `cq-md` this
129
+ // falls back to the column, which is the same rule
130
+ // `Board` follows and for the same reason: the owner
131
+ // browses zoomed in, so a window's width is not the
132
+ // number of pixels anything actually has.
133
+ isHorizontal && "cq-md:flex-row cq-md:gap-0",
134
+ )}
135
+ >
136
+ {steps.map((step, index) => {
137
+ const status = step.status ?? "upcoming"
138
+ const isLast = index === steps.length - 1
139
+
140
+ return (
141
+ <li
142
+ className={toClassName(
143
+ "relative grid grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)] gap-x-3",
144
+ isHorizontal &&
145
+ "cq-md:flex cq-md:min-w-0 cq-md:flex-1 cq-md:flex-col cq-md:gap-y-2",
146
+ // Blocked and upcoming recede; the step you can act
147
+ // on is the one that keeps full contrast. Opacity
148
+ // rather than a muted token so the step's own
149
+ // contents dim with it — a task list at full strength
150
+ // under a dimmed heading reads as startable.
151
+ (status === "blocked" ||
152
+ status === "upcoming") &&
153
+ "opacity-70",
154
+ )}
155
+ key={step.key}
156
+ >
157
+ <span
158
+ className={toClassName(
159
+ "col-start-1 row-start-1 grid size-7 place-items-center rounded-full border text-sm tabular-nums",
160
+ STATUS_MARKER_CLASS[status],
161
+ isHorizontal && "cq-md:col-start-1",
162
+ )}
163
+ >
164
+ {step.marker ?? index + 1}
165
+ </span>
166
+
167
+ {/* Absent on the last step, so nothing draws a line
168
+ into empty space. */}
169
+ {isLast ? null : (
170
+ <span
171
+ aria-hidden
172
+ className={toClassName(
173
+ "col-start-1 row-start-2 my-1 w-0.5 justify-self-center rounded-full",
174
+ // Solid behind finished work, dashed ahead of it
175
+ // — the second visual channel that keeps this
176
+ // from being colour alone.
177
+ status === "done"
178
+ ? "bg-intent-success-solid"
179
+ : "bg-border-subtle",
180
+ isHorizontal &&
181
+ "cq-md:absolute cq-md:top-3.5 cq-md:start-7 cq-md:my-0 cq-md:h-0.5 cq-md:w-[calc(100%-1.75rem)]",
182
+ )}
183
+ />
184
+ )}
185
+
186
+ <div
187
+ className={toClassName(
188
+ "col-start-2 row-span-2 row-start-1 min-w-0",
189
+ isHorizontal && "cq-md:col-start-1",
190
+ )}
191
+ >
192
+ <Heading className="m-0 flex flex-wrap items-center gap-2 text-md font-medium text-content-primary">
193
+ {step.label}
194
+
195
+ {/* The status as a WORD. Four marker colours is
196
+ four colours as far as assistive technology is
197
+ concerned, and this is the only channel that
198
+ survives a screen reader, a greyscale print and
199
+ the ePaper build at once. */}
200
+ <VisuallyHidden>
201
+ {STATUS_LABEL[status]}
202
+ </VisuallyHidden>
203
+ </Heading>
204
+
205
+ {step.description == null ? null : (
206
+ <p className="mt-0.5 mb-0 text-sm text-content-secondary">
207
+ {step.description}
208
+ </p>
209
+ )}
210
+
211
+ {step.content == null ? null : (
212
+ <div className="mt-2">{step.content}</div>
213
+ )}
214
+ </div>
215
+ </li>
216
+ )
217
+ })}
218
+ </ol>
219
+ </div>
220
+ )
221
+ }
222
+
223
+ /**
224
+ * Written out in full, never interpolated. Tailwind v4 scans source
225
+ * text for COMPLETE class strings, so `` `bg-intent-${x}-solid` ``
226
+ * generates nothing at all and the element paints transparent with
227
+ * no error and no failing test —
228
+ * `tailwindCandidates.test.ts` is what makes this near-duplication
229
+ * safe to keep.
230
+ */
231
+ const STATUS_MARKER_CLASS: Record<StepStatus, string> = {
232
+ blocked:
233
+ "border-intent-warning-border bg-intent-warning-surface text-intent-warning-content",
234
+ current:
235
+ "border-intent-accent-border bg-intent-accent-surface text-intent-accent-content",
236
+ done: "border-intent-success-border bg-intent-success-solid text-intent-success-on-solid",
237
+ upcoming:
238
+ "border-border-subtle bg-surface-raised text-content-secondary",
239
+ }
240
+
241
+ const STATUS_LABEL: Record<StepStatus, string> = {
242
+ blocked: "Blocked",
243
+ current: "In progress",
244
+ done: "Done",
245
+ upcoming: "Not started",
246
+ }
package/src/index.ts CHANGED
@@ -328,6 +328,13 @@ export type {
328
328
  export { SortableTableHeader } from "./SortableTableHeader/SortableTableHeader.tsx"
329
329
  export type { SpinnerProps } from "./Spinner/Spinner.tsx"
330
330
  export { Spinner } from "./Spinner/Spinner.tsx"
331
+ export type {
332
+ Step,
333
+ StepperOrientation,
334
+ StepperProps,
335
+ StepStatus,
336
+ } from "./Stepper/Stepper.tsx"
337
+ export { Stepper } from "./Stepper/Stepper.tsx"
331
338
  export type {
332
339
  SwatchAppearance,
333
340
  SwatchProps,
@@ -98,6 +98,34 @@ blank line at the top and bottom of the block that reads as a rendering bug. A l
98
98
  stays, because hiding `-` turns an unordered list into indented prose — Obsidian substitutes
99
99
  a `•`, which means choosing a width, and this component has no business choosing one.
100
100
 
101
+ ## Tables render, and the caret edits them
102
+
103
+ <Canvas of={MarkdownEditorCodeMirrorStories.Tables} />
104
+
105
+ A table is the one construct on this surface whose meaning is *geometry*. Every other
106
+ decoration hangs on text CodeMirror draws anyway; a column cannot, because column two's text
107
+ is on four separate lines. So the pipes stop being drawn and a real `<table>` is drawn
108
+ instead — alignment from the delimiter row, and `**bold**`, `` `code` ``, links and images
109
+ rendering inside the cells exactly as they do in a paragraph.
110
+
111
+ **Click a cell and the markdown comes back with the caret in that cell.** That is the same
112
+ trade as a link or an image, and it is the only way in: you cannot edit a column you cannot
113
+ see. Arrow keys work too — Down from the line above enters at the top row, Up from below
114
+ enters at the bottom — because CodeMirror otherwise steps *over* a block widget and the
115
+ table would be unreachable without a mouse.
116
+
117
+ Three decisions worth knowing:
118
+
119
+ - **The widest row wins, not the header.** GFM drops cells past the header's width. An
120
+ editor that did the same would put text you typed on screen nowhere, so short rows gain
121
+ empty cells instead and nothing is ever hidden.
122
+ - **An empty cell keeps its column.** The parser emits no cell node for `| |`; the `|`
123
+ positions are what the columns are read from, so a blank cell does not shift the row.
124
+ - **A wide table scrolls inside its own block** rather than pushing the editor sideways.
125
+
126
+ `\|` — the only way to put a pipe *in* a cell — now conceals its backslash like any other
127
+ marker, in tables and everywhere else.
128
+
101
129
  ## Clicking a link opens it
102
130
 
103
131
  Obsidian's behaviour, not VS Code's: where the markup is hidden, a link that looks like a
@@ -47,6 +47,28 @@ ip -br addr show
47
47
  Replacement rails ordered from https://example.invalid/product?id=1234
48
48
  `
49
49
 
50
+ /**
51
+ * Alignment from the delimiter row, inline markup inside cells, a
52
+ * deliberately blank cell, and a row that is one column short —
53
+ * every table decision in one fixture.
54
+ */
55
+ const TABLES = `## Rack inventory
56
+
57
+ | Port | Goes to | **Speed** | Notes |
58
+ | :--- | :---: | ---: | --- |
59
+ | 1 | uplink | 10G | swapped \`2026-08-19\` |
60
+ | 2 | | 1G | see [the runbook](https://example.invalid/runbook) |
61
+ | 3 | shelf | 1G |
62
+ | 4 | ~~spare~~ | — | pulled \\| relabel |
63
+
64
+ A table with no outer pipes is still a table:
65
+
66
+ Disk | Size
67
+ --- | ---:
68
+ front-left | 18 TB
69
+ front-right | 18 TB
70
+ `
71
+
50
72
  const meta = {
51
73
  title: "Components/Controls/MarkdownEditorCodeMirror",
52
74
  component: MarkdownEditorCodeMirror,
@@ -145,3 +167,23 @@ export const CaretReveal: Story = {
145
167
  label: "Description",
146
168
  },
147
169
  }
170
+
171
+ /**
172
+ * The construct whose meaning is geometry.
173
+ *
174
+ * Everything else on this surface is a decoration hung on text that
175
+ * is drawn anyway — a column is not, because column two's text is
176
+ * on four separate lines. So the pipes stop being drawn and a real
177
+ * table is drawn instead, alignment and all, with `**bold**` and
178
+ * links rendering inside the cells like anywhere else.
179
+ *
180
+ * Click a cell and the markdown comes back with the caret in that
181
+ * cell — the same trade the rest of the surface makes for a link or
182
+ * an image.
183
+ */
184
+ export const Tables: Story = {
185
+ args: {
186
+ defaultValue: TABLES,
187
+ label: "Description",
188
+ },
189
+ }