@charcuterie/ui 3.3.0 → 3.4.1

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 (95) hide show
  1. package/README.md +8 -0
  2. package/dist/Field/Field.d.ts.map +1 -1
  3. package/dist/Field/Field.js +19 -1
  4. package/dist/Field/Field.js.map +1 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  6. package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
  7. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  8. package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
  9. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
  10. package/dist/MarkdownEditor/markdownCommands.js +47 -0
  11. package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
  12. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  13. package/dist/MarkdownEditor/markdownSpans.js +30 -0
  14. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  15. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
  16. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
  17. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
  18. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
  19. package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
  20. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
  21. package/dist/markdownEditorCodeMirror/index.js +14 -0
  22. package/dist/markdownEditorCodeMirror/index.js.map +1 -0
  23. package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
  24. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
  25. package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
  26. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
  27. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
  28. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
  29. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
  30. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
  31. package/package.json +36 -1
  32. package/src/Accordion/Accordion.stories.tsx +1 -1
  33. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +1 -1
  34. package/src/Alert/Alert.stories.tsx +1 -1
  35. package/src/Badge/Badge.stories.tsx +1 -1
  36. package/src/Board/Board.stories.tsx +1 -1
  37. package/src/Button/Button.stories.tsx +1 -1
  38. package/src/ButtonLink/ButtonLink.stories.tsx +1 -1
  39. package/src/Card/Card.stories.tsx +1 -1
  40. package/src/Checkbox/Checkbox.stories.tsx +1 -1
  41. package/src/ColorSchemeSwitcher/ColorSchemeSwitcher.stories.tsx +1 -1
  42. package/src/ColorSchemeToggle/ColorSchemeToggle.stories.tsx +1 -1
  43. package/src/Combobox/Combobox.stories.tsx +1 -1
  44. package/src/DataTable/DataTable.stories.tsx +1 -1
  45. package/src/DatePicker/DatePicker.stories.tsx +1 -1
  46. package/src/Dialog/Dialog.stories.tsx +1 -1
  47. package/src/EmptyState/EmptyState.stories.tsx +1 -1
  48. package/src/Field/Field.stories.tsx +1 -1
  49. package/src/Field/Field.test.tsx +35 -0
  50. package/src/Field/Field.tsx +18 -0
  51. package/src/FileDropZone/FileDropZone.stories.tsx +1 -1
  52. package/src/Header/Header.stories.tsx +1 -1
  53. package/src/IconButton/IconButton.stories.tsx +1 -1
  54. package/src/Lightbox/Lightbox.stories.tsx +1 -1
  55. package/src/Listbox/Listbox.stories.tsx +1 -1
  56. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +1 -1
  57. package/src/LogViewer/LogViewer.stories.tsx +1 -1
  58. package/src/Main/Main.stories.tsx +1 -1
  59. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +1 -1
  60. package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
  61. package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
  62. package/src/MarkdownEditor/markdownCommands.ts +60 -0
  63. package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
  64. package/src/MarkdownEditor/markdownSpans.ts +35 -0
  65. package/src/MediaTile/MediaTile.stories.tsx +1 -1
  66. package/src/Menu/Menu.stories.tsx +1 -1
  67. package/src/Modal/Modal.stories.tsx +1 -1
  68. package/src/Picker/Picker.stories.tsx +1 -1
  69. package/src/Popover/Popover.stories.tsx +1 -1
  70. package/src/ProgressBar/ProgressBar.stories.tsx +1 -1
  71. package/src/QueryBuilder/QueryBuilder.stories.tsx +1 -1
  72. package/src/RadioGroup/RadioGroup.stories.tsx +1 -1
  73. package/src/Rail/Rail.stories.tsx +1 -1
  74. package/src/SegmentedControl/SegmentedControl.stories.tsx +1 -1
  75. package/src/Select/Select.stories.tsx +17 -6
  76. package/src/Shell/Shell.stories.tsx +1 -1
  77. package/src/Skeleton/Skeleton.stories.tsx +1 -1
  78. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +1 -1
  79. package/src/Spinner/Spinner.stories.tsx +1 -1
  80. package/src/Swatch/Swatch.stories.tsx +1 -1
  81. package/src/Switch/Switch.stories.tsx +1 -1
  82. package/src/Tabs/Tabs.stories.tsx +1 -1
  83. package/src/TextLink/TextLink.stories.tsx +1 -1
  84. package/src/Toast/Toast.stories.tsx +1 -1
  85. package/src/Toolbar/Toolbar.stories.tsx +1 -1
  86. package/src/Tooltip/Tooltip.stories.tsx +1 -1
  87. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +1 -1
  88. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
  89. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
  90. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
  91. package/src/markdownEditorCodeMirror/index.ts +26 -0
  92. package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
  93. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
  94. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
  95. package/src/sourceRules.test.ts +20 -0
@@ -10,7 +10,7 @@ import { FileDropZone } from "./FileDropZone.tsx"
10
10
  const noop = () => undefined
11
11
 
12
12
  const meta = {
13
- title: "Components/FileDropZone",
13
+ title: "Components/Controls/FileDropZone",
14
14
  component: FileDropZone,
15
15
  parameters: { layout: "padded" },
16
16
  args: {
@@ -16,7 +16,7 @@ import {
16
16
  import { Header } from "./Header.tsx"
17
17
 
18
18
  const meta = {
19
- title: "Components/Header",
19
+ title: "Components/Layout/Header",
20
20
  component: Header,
21
21
  parameters: { layout: "fullscreen" },
22
22
  argTypes: {
@@ -27,7 +27,7 @@ import { IconButton } from "./IconButton.tsx"
27
27
  const UNDO_ICON = <UndoIcon />
28
28
 
29
29
  const meta = {
30
- title: "Components/IconButton",
30
+ title: "Components/Actions/IconButton",
31
31
  component: IconButton,
32
32
  parameters: { layout: "padded" },
33
33
  argTypes: { size: controlSizeArgType },
@@ -33,7 +33,7 @@ const PosterThumb = (): ReactNode => (
33
33
  )
34
34
 
35
35
  const meta = {
36
- title: "Components/Lightbox",
36
+ title: "Components/Overlays/Lightbox",
37
37
  component: Lightbox,
38
38
  parameters: { layout: "padded" },
39
39
  args: {
@@ -70,7 +70,7 @@ const TRACKS: ListboxItem[] = [
70
70
  ]
71
71
 
72
72
  const meta = {
73
- title: "Components/Listbox",
73
+ title: "Components/Controls/Listbox",
74
74
  component: Listbox,
75
75
  parameters: { layout: "padded" },
76
76
  argTypes: { placement: placementArgType },
@@ -24,7 +24,7 @@ const CONNECTION_STATUSES = [
24
24
  ] as const
25
25
 
26
26
  const meta = {
27
- title: "Components/LiveStatusIndicator",
27
+ title: "Components/Feedback/LiveStatusIndicator",
28
28
  component: LiveStatusIndicator,
29
29
  parameters: { layout: "padded" },
30
30
  argTypes: { status: connectionStatusArgType },
@@ -46,7 +46,7 @@ const LONG_LINES: LogLine[] = Array.from(
46
46
  )
47
47
 
48
48
  const meta = {
49
- title: "Components/LogViewer",
49
+ title: "Components/Data/LogViewer",
50
50
  component: LogViewer,
51
51
  parameters: { layout: "padded" },
52
52
  args: {
@@ -24,7 +24,7 @@ const CONTENT_WIDTH_OPTIONS = [
24
24
  ] as const
25
25
 
26
26
  const meta = {
27
- title: "Components/Main",
27
+ title: "Components/Layout/Main",
28
28
  component: Main,
29
29
  parameters: { layout: "fullscreen" },
30
30
  argTypes: {
@@ -45,7 +45,7 @@ ip -br addr show
45
45
  const noop = () => undefined
46
46
 
47
47
  const meta = {
48
- title: "Components/MarkdownEditor",
48
+ title: "Components/Controls/MarkdownEditor",
49
49
  component: MarkdownEditor,
50
50
  parameters: { layout: "padded" },
51
51
  args: {
@@ -18,12 +18,14 @@ import {
18
18
  indentLines,
19
19
  insertLink,
20
20
  insertText,
21
+ isLinkPaste,
21
22
  MARKDOWN_LINE_PREFIXES,
22
23
  outdentLines,
23
24
  toggleHeading,
24
25
  toggleInlineMarker,
25
26
  toggleLinePrefix,
26
27
  toMarkdownImage,
28
+ wrapSelectionInLink,
27
29
  } from "./markdownCommands.ts"
28
30
  import type { MarkdownSpanKind } from "./markdownSpans.ts"
29
31
  import {
@@ -464,17 +466,41 @@ export const MarkdownEditor = ({
464
466
  pasteEvent.clipboardData.files,
465
467
  ).filter(isImageFile)
466
468
 
467
- if (files.length === 0 || !onUploadImage) {
468
- // Left to the browser on purpose. A `<textarea>` pastes
469
- // `text/plain` and nothing else, so the no-HTML guarantee
470
- // needs no code here at all — intercepting the paste to
471
- // "sanitise" it would be the version that can have a bug.
469
+ if (files.length > 0 && onUploadImage) {
470
+ pasteEvent.preventDefault()
471
+
472
+ uploadImages(files)
473
+
472
474
  return
473
475
  }
474
476
 
475
- pasteEvent.preventDefault()
477
+ const pasted =
478
+ pasteEvent.clipboardData.getData("text/plain")
476
479
 
477
- uploadImages(files)
480
+ const selection = readSelection()
481
+
482
+ // A URL pasted over selected text becomes a link, the way it
483
+ // does in every editor anyone uses. Reading `text/plain` here
484
+ // is not a hole in the no-HTML guarantee — that flavour is the
485
+ // only one a `<textarea>` would have taken anyway, and what
486
+ // goes back in is markdown built by a command.
487
+ if (
488
+ isLinkPaste(pasted) &&
489
+ selection.selectionStart !== selection.selectionEnd
490
+ ) {
491
+ pasteEvent.preventDefault()
492
+
493
+ applyEdit(
494
+ wrapSelectionInLink(selection, pasted.trim()),
495
+ )
496
+
497
+ return
498
+ }
499
+
500
+ // Everything else is left to the browser on purpose. A
501
+ // `<textarea>` pastes `text/plain` and nothing else, so the
502
+ // no-HTML guarantee needs no code here — intercepting the paste
503
+ // to "sanitise" it would be the version that can have a bug.
478
504
  }
479
505
 
480
506
  const handleDrop = (
@@ -7,12 +7,14 @@ import {
7
7
  insertImage,
8
8
  insertLink,
9
9
  insertText,
10
+ isLinkPaste,
10
11
  MARKDOWN_LINE_PREFIXES,
11
12
  outdentLines,
12
13
  toggleHeading,
13
14
  toggleInlineMarker,
14
15
  toggleLinePrefix,
15
16
  toMarkdownImage,
17
+ wrapSelectionInLink,
16
18
  } from "./markdownCommands.ts"
17
19
 
18
20
  const at = (text: string, marker = "|") => {
@@ -382,3 +384,56 @@ test("the minimal edit is the smallest range that explains the change", () => {
382
384
  text: "",
383
385
  })
384
386
  })
387
+
388
+ /**
389
+ * Pasting a URL over selected text is what every editor the owner
390
+ * uses already does, and its absence is why a link pasted into a
391
+ * Docket description stayed flat text.
392
+ */
393
+ test("a URL pasted over a selection wraps it", () => {
394
+ expect(
395
+ wrapSelectionInLink(
396
+ at("see |the docs| now"),
397
+ "https://example.com",
398
+ ),
399
+ ).toEqual({
400
+ // After the whole insertion, not inside the destination: the
401
+ // URL is already known, so parking the caret in it would ask
402
+ // the user to replace what they just pasted.
403
+ selectionEnd: 35,
404
+ selectionStart: 35,
405
+ text: "see [the docs](https://example.com) now",
406
+ })
407
+ })
408
+
409
+ test("a URL pasted with no selection is inserted plainly", () => {
410
+ // Autolink renders a bare URL as a link anyway, so wrapping it
411
+ // with no text would produce `[](url)` — an empty link.
412
+ expect(
413
+ wrapSelectionInLink(
414
+ at("see | now"),
415
+ "https://example.com",
416
+ ),
417
+ ).toEqual({
418
+ selectionEnd: 23,
419
+ selectionStart: 23,
420
+ text: "see https://example.com now",
421
+ })
422
+ })
423
+
424
+ test.each([
425
+ ["https://example.com", true],
426
+ ["http://example.com/a?b=c#d", true],
427
+ [" https://example.com ", true],
428
+ ["not a url", false],
429
+ ["", false],
430
+ // Deliberately strict: a paste that is only *mostly* a URL is
431
+ // left alone, because mangling a literal paste is worse than
432
+ // not linkifying one.
433
+ ["see https://example.com", false],
434
+ ["https://a.com https://b.com", false],
435
+ ["mailto:someone@example.com", false],
436
+ ["www.example.com", false],
437
+ ])("isLinkPaste(%j) is %s", (pasted, isExpected) => {
438
+ expect(isLinkPaste(pasted)).toBe(isExpected)
439
+ })
@@ -394,6 +394,66 @@ export const insertLink = (
394
394
  })
395
395
  }
396
396
 
397
+ /**
398
+ * Does this pasted string want to become a link?
399
+ *
400
+ * Deliberately strict. A paste that is *mostly* a URL — a sentence
401
+ * with one in it, two URLs on two lines — is left alone, because
402
+ * the cost of guessing wrong is silently mangling something the
403
+ * user meant to paste literally. One absolute `http(s)` URL, no
404
+ * inner whitespace, nothing either side of it.
405
+ *
406
+ * `mailto:` and bare `www.` are excluded on the same principle:
407
+ * they are the cases where "did they mean a link?" genuinely has
408
+ * two answers.
409
+ */
410
+ export const isLinkPaste = (pasted: string): boolean =>
411
+ /^https?:\/\/[^\s<>]+$/.test(pasted.trim())
412
+
413
+ /**
414
+ * Paste a URL over selected text and get `[selected](url)`.
415
+ *
416
+ * Every editor the owner uses does this — Outline, Notion, Slack,
417
+ * GitHub — and its absence is why a link pasted into Docket stayed
418
+ * flat text nobody could click. It is the one paste behaviour worth
419
+ * intercepting, and it is still markdown in and markdown out.
420
+ *
421
+ * Unlike `insertLink`, the caret lands **after** the whole
422
+ * insertion rather than inside the destination: the URL is already
423
+ * known, so parking the selection in it would ask the user to
424
+ * replace what they just pasted.
425
+ *
426
+ * With an empty selection this is a plain insertion of the URL —
427
+ * autolink renders a bare URL as a link anyway, so wrapping it in
428
+ * `[](…)` with no text would produce an empty link instead.
429
+ */
430
+ export const wrapSelectionInLink = (
431
+ state: MarkdownSelection,
432
+ url: string,
433
+ ): MarkdownSelection => {
434
+ const selected = state.text.slice(
435
+ state.selectionStart,
436
+ state.selectionEnd,
437
+ )
438
+
439
+ if (selected === "") {
440
+ return insertText(state, url)
441
+ }
442
+
443
+ const insertion = `[${selected}](${url})`
444
+
445
+ const caret = state.selectionStart + insertion.length
446
+
447
+ return replaceRange({
448
+ end: state.selectionEnd,
449
+ insertion,
450
+ selectionEnd: caret,
451
+ selectionStart: caret,
452
+ start: state.selectionStart,
453
+ text: state.text,
454
+ })
455
+ }
456
+
397
457
  /**
398
458
  * What Enter should do inside a list, a task list or a blockquote —
399
459
  * or `null` when it should do the ordinary thing and the component
@@ -215,3 +215,68 @@ test("the caret's line is found from its offset", () => {
215
215
 
216
216
  expect(toLineIndex(text, 9999)).toBe(2)
217
217
  })
218
+
219
+ /**
220
+ * Gap 2 of Docket's markdown handoff. A URL pasted into a
221
+ * description used to tokenize as prose — no `url` span, nothing to
222
+ * paint, and indistinguishable from the words around it.
223
+ */
224
+ test("a bare URL paints as a URL without being marked up", () => {
225
+ expect(
226
+ toInlineSpans("see https://example.com now"),
227
+ ).toEqual([
228
+ { kind: "plain", text: "see " },
229
+ { kind: "url", text: "https://example.com" },
230
+ { kind: "plain", text: " now" },
231
+ ])
232
+ })
233
+
234
+ test("a sentence's full stop is not part of the URL", () => {
235
+ expect(
236
+ toInlineSpans("read https://example.com/a."),
237
+ ).toEqual([
238
+ { kind: "plain", text: "read " },
239
+ { kind: "url", text: "https://example.com/a" },
240
+ { kind: "plain", text: "." },
241
+ ])
242
+ })
243
+
244
+ test("a parenthesised URL does not swallow the paren", () => {
245
+ expect(
246
+ toInlineSpans("(see https://example.com)"),
247
+ ).toEqual([
248
+ { kind: "plain", text: "(see " },
249
+ { kind: "url", text: "https://example.com" },
250
+ { kind: "plain", text: ")" },
251
+ ])
252
+ })
253
+
254
+ /**
255
+ * The destination inside an explicit link is already a `url` span
256
+ * emitted by the link rule, and the link rule runs first. If
257
+ * autolink won here, the `](` and `)` would stop being markers.
258
+ */
259
+ test("an explicit link still tokenizes as a link", () => {
260
+ expect(
261
+ toInlineSpans("[docs](https://example.com)"),
262
+ ).toEqual([
263
+ { kind: "marker", text: "[" },
264
+ { kind: "link", text: "docs" },
265
+ { kind: "marker", text: "](" },
266
+ { kind: "url", text: "https://example.com" },
267
+ { kind: "marker", text: ")" },
268
+ ])
269
+ })
270
+
271
+ /**
272
+ * Autolink runs before emphasis, which fixes a bug that predates
273
+ * it: the stretch between two underscores in a URL used to paint
274
+ * as italic text.
275
+ */
276
+ test("underscores inside a URL are not emphasis", () => {
277
+ expect(
278
+ toInlineSpans("https://example.com/a_b_c"),
279
+ ).toEqual([
280
+ { kind: "url", text: "https://example.com/a_b_c" },
281
+ ])
282
+ })
@@ -121,8 +121,22 @@ const TASK_MARKER_PATTERN = /^\[[ xX]\][ \t]*/
121
121
  * - **Image before link**, because `![alt](url)` starts with a
122
122
  * valid link at index 1.
123
123
  * - **Strong before emphasis**, because `**` starts with `*`.
124
+ * - **Link before autolink**, because the `https://…` inside
125
+ * `[text](https://…)` is a destination, not a bare URL, and
126
+ * matching it as one would paint the parentheses as link text.
124
127
  */
125
128
  const INLINE_PATTERNS = {
129
+ /**
130
+ * A bare URL, as GFM autolinks it.
131
+ *
132
+ * Trailing punctuation is excluded from the match rather than
133
+ * trimmed after: `see https://example.com.` ends a sentence, and
134
+ * the full stop belongs to the prose. The closing paren is
135
+ * likewise left out so `(see https://example.com)` does not
136
+ * swallow it — at the cost of clipping a URL that genuinely ends
137
+ * in one, which is the rarer and less annoying failure.
138
+ */
139
+ autolink: /https?:\/\/[^\s<>[\]()]+[^\s<>[\]().,;:!?'"]/y,
126
140
  code: /(`+)([^\n]*?)\1/y,
127
141
  emphasis: /([*_])(?=[^\s*_])([^\n*_]*?[^\s*_])\1/y,
128
142
  escape: /\\([\\`*_{}[\]()#+\-.!>~|])/y,
@@ -243,6 +257,27 @@ export const toInlineSpans = (
243
257
  continue
244
258
  }
245
259
 
260
+ INLINE_PATTERNS.autolink.lastIndex = index
261
+
262
+ const autolinked = INLINE_PATTERNS.autolink.exec(text)
263
+
264
+ if (autolinked) {
265
+ // One span, no markers — there is no syntax to dim, because
266
+ // the user typed none. It paints as a URL so that a pasted
267
+ // link reads as a link, which is the whole complaint this
268
+ // answers: a URL dropped into a description used to be
269
+ // indistinguishable from prose.
270
+ //
271
+ // Running before `emphasis` also fixes a bug nobody filed:
272
+ // a URL with two underscores in it used to italicise the
273
+ // stretch between them.
274
+ pushSpan(spans, "url", autolinked[0])
275
+
276
+ index += autolinked[0].length
277
+
278
+ continue
279
+ }
280
+
246
281
  INLINE_PATTERNS.strong.lastIndex = index
247
282
 
248
283
  const strong = INLINE_PATTERNS.strong.exec(text)
@@ -25,7 +25,7 @@ const toPosterSrc = (label: string) =>
25
25
  const MISSING_SRC = "/charcuterie-missing-poster.png"
26
26
 
27
27
  const meta = {
28
- title: "Components/MediaTile",
28
+ title: "Components/Data/MediaTile",
29
29
  component: MediaTile,
30
30
  parameters: { layout: "padded" },
31
31
  args: { aspectRatio: "poster" },
@@ -141,7 +141,7 @@ const TooltipTriggerHarness = ({
141
141
  }
142
142
 
143
143
  const meta = {
144
- title: "Components/Menu",
144
+ title: "Components/Actions/Menu",
145
145
  component: Menu,
146
146
  parameters: { layout: "centered" },
147
147
  argTypes: { placement: placementArgType },
@@ -6,7 +6,7 @@ import { Button } from "../Button/Button.tsx"
6
6
  import { Modal } from "./Modal.tsx"
7
7
 
8
8
  const meta = {
9
- title: "Components/Modal",
9
+ title: "Components/Overlays/Modal",
10
10
  component: Modal,
11
11
  parameters: { layout: "padded" },
12
12
  args: {
@@ -57,7 +57,7 @@ const TRACKS: PickerOption[] = [
57
57
  ]
58
58
 
59
59
  const meta = {
60
- title: "Components/Picker",
60
+ title: "Components/Controls/Picker",
61
61
  component: Picker,
62
62
  parameters: { layout: "padded" },
63
63
  argTypes: {
@@ -14,7 +14,7 @@ import {
14
14
  import { Popover } from "./Popover.tsx"
15
15
 
16
16
  const meta = {
17
- title: "Components/Popover",
17
+ title: "Components/Overlays/Popover",
18
18
  component: Popover,
19
19
  parameters: { layout: "padded" },
20
20
  argTypes: { placement: placementArgType },
@@ -20,7 +20,7 @@ const DISC_THRESHOLDS: ProgressThreshold[] = [
20
20
  ]
21
21
 
22
22
  const meta = {
23
- title: "Components/ProgressBar",
23
+ title: "Components/Feedback/ProgressBar",
24
24
  component: ProgressBar,
25
25
  parameters: { layout: "padded" },
26
26
  argTypes: { intent: intentArgType },
@@ -173,7 +173,7 @@ const QueryBuilderHarness = ({
173
173
  }
174
174
 
175
175
  const meta = {
176
- title: "Components/QueryBuilder",
176
+ title: "Components/Controls/QueryBuilder",
177
177
  component: QueryBuilder,
178
178
  parameters: { layout: "padded" },
179
179
  args: {
@@ -24,7 +24,7 @@ const NAMING_ITEMS: RadioItem[] = [
24
24
  ]
25
25
 
26
26
  const meta = {
27
- title: "Components/RadioGroup",
27
+ title: "Components/Controls/RadioGroup",
28
28
  component: RadioGroup,
29
29
  parameters: { layout: "padded" },
30
30
  argTypes: { size: controlSizeArgType },
@@ -13,7 +13,7 @@ import {
13
13
  import { Rail } from "./Rail.tsx"
14
14
 
15
15
  const meta = {
16
- title: "Components/Rail",
16
+ title: "Components/Layout/Rail",
17
17
  component: Rail,
18
18
  parameters: { layout: "fullscreen" },
19
19
  argTypes: {
@@ -24,7 +24,7 @@ const COLUMN_ITEMS: SegmentedItem[] = [
24
24
  ]
25
25
 
26
26
  const meta = {
27
- title: "Components/SegmentedControl",
27
+ title: "Components/Controls/SegmentedControl",
28
28
  component: SegmentedControl,
29
29
  parameters: { layout: "padded" },
30
30
  argTypes: { size: controlSizeArgType },
@@ -41,13 +41,24 @@ const GROUPED: SelectItem[] = [
41
41
  ]
42
42
 
43
43
  const meta = {
44
- // `Deprecated/`, not `Components/`, and that is the whole point of
45
- // moving it: the sidebar is the first place an agent looks for a
46
- // picker, and a `Select` sitting between `Rail` and `Shell` reads
47
- // as a component in good standing. Nothing new gets one — `Picker`
48
- // is the drop-in. See the 2026-08-20 deprecation record.
49
- title: "Deprecated/Select",
44
+ title: "Components/Controls/Select",
50
45
  component: Select,
46
+ /**
47
+ * Deprecated — `Picker` is the drop-in. This is a **label, not a
48
+ * relocation**: the component still lives beside the other
49
+ * controls, where somebody looking for a picker will actually
50
+ * find it, and the sidebar paints a badge on it there. A
51
+ * `Deprecated/` section instead of a label hides the warning from
52
+ * everyone who does not already know to look for it, which is
53
+ * exactly backwards.
54
+ *
55
+ * Manager entries take the **intersection** of their children's
56
+ * tags, so tagging the meta puts the badge on the `Select` node
57
+ * itself, not just on each story underneath it.
58
+ * `sidebar.renderLabel` in `@charcuterie/storybook-config/manager`
59
+ * is what draws it.
60
+ */
61
+ tags: ["deprecated"],
51
62
  parameters: { layout: "padded" },
52
63
  argTypes: { size: controlSizeArgType },
53
64
  args: {
@@ -29,7 +29,7 @@ const CONTENT_WIDTH_OPTIONS = [
29
29
  ] as const
30
30
 
31
31
  const meta = {
32
- title: "Components/Shell",
32
+ title: "Components/Layout/Shell",
33
33
  component: Shell,
34
34
  parameters: { layout: "fullscreen" },
35
35
  argTypes: {
@@ -10,7 +10,7 @@ import { Card } from "../Card/Card.tsx"
10
10
  import { Skeleton } from "./Skeleton.tsx"
11
11
 
12
12
  const meta = {
13
- title: "Components/Skeleton",
13
+ title: "Components/Feedback/Skeleton",
14
14
  component: Skeleton,
15
15
  parameters: { layout: "padded" },
16
16
  args: { lineCount: 1, shape: "block" },
@@ -30,7 +30,7 @@ const CELL_CLASS =
30
30
  * every table gets wrong the same way.
31
31
  */
32
32
  const meta = {
33
- title: "Components/SortableTableHeader",
33
+ title: "Components/Data/SortableTableHeader",
34
34
  component: SortableTableHeader,
35
35
  parameters: { layout: "padded" },
36
36
  args: { onSort: noop },
@@ -11,7 +11,7 @@ import {
11
11
  import { Spinner } from "./Spinner.tsx"
12
12
 
13
13
  const meta = {
14
- title: "Components/Spinner",
14
+ title: "Components/Feedback/Spinner",
15
15
  component: Spinner,
16
16
  parameters: { layout: "padded" },
17
17
  argTypes: { size: controlSizeArgType },
@@ -22,7 +22,7 @@ const STICKERS = [
22
22
  ] as const
23
23
 
24
24
  const meta = {
25
- title: "Components/Swatch",
25
+ title: "Components/Controls/Swatch",
26
26
  component: Swatch,
27
27
  parameters: { layout: "padded" },
28
28
  argTypes: { color: { control: "color" } },
@@ -9,7 +9,7 @@ import {
9
9
  import { Switch } from "./Switch.tsx"
10
10
 
11
11
  const meta = {
12
- title: "Components/Switch",
12
+ title: "Components/Controls/Switch",
13
13
  component: Switch,
14
14
  parameters: { layout: "padded" },
15
15
  argTypes: { size: controlSizeArgType },
@@ -44,7 +44,7 @@ const BAY_TABS: TabItem[] = [
44
44
  ]
45
45
 
46
46
  const meta = {
47
- title: "Components/Tabs",
47
+ title: "Components/Layout/Tabs",
48
48
  component: Tabs,
49
49
  parameters: { layout: "padded" },
50
50
  argTypes: {
@@ -39,7 +39,7 @@ const INTENTS = [
39
39
  ] as const
40
40
 
41
41
  const meta = {
42
- title: "Components/TextLink",
42
+ title: "Components/Actions/TextLink",
43
43
  component: TextLink,
44
44
  parameters: { layout: "padded" },
45
45
  argTypes: {
@@ -20,7 +20,7 @@ const noop = () => undefined
20
20
  * is the documented way to hold one for an action anyway.
21
21
  */
22
22
  const meta = {
23
- title: "Components/Toast",
23
+ title: "Components/Overlays/Toast",
24
24
  component: Toast,
25
25
  parameters: { layout: "padded" },
26
26
  argTypes: { intent: intentArgType },
@@ -126,7 +126,7 @@ const Frame = ({
126
126
  )
127
127
 
128
128
  const meta = {
129
- title: "Components/Toolbar",
129
+ title: "Components/Actions/Toolbar",
130
130
  component: Toolbar,
131
131
  parameters: { layout: "padded" },
132
132
  argTypes: { size: controlSizeArgType },
@@ -11,7 +11,7 @@ import { SettingsIcon } from "../icons.storyHelpers.tsx"
11
11
  import { Tooltip } from "./Tooltip.tsx"
12
12
 
13
13
  const meta = {
14
- title: "Components/Tooltip",
14
+ title: "Components/Overlays/Tooltip",
15
15
  component: Tooltip,
16
16
  parameters: { layout: "centered" },
17
17
  argTypes: { placement: placementArgType },