dowel-ui 0.30.0 → 0.31.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 (102) hide show
  1. package/README.md +8 -9
  2. package/dist/eslint/index.d.ts +1 -0
  3. package/dist/eslint/index.d.ts.map +1 -1
  4. package/dist/eslint/index.js +13 -4
  5. package/dist/eslint/index.js.map +1 -1
  6. package/dist/eslint/no-implicit-locale.d.ts +3 -0
  7. package/dist/eslint/no-implicit-locale.d.ts.map +1 -0
  8. package/dist/eslint/no-implicit-locale.js +83 -0
  9. package/dist/eslint/no-implicit-locale.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/locale.d.ts +16 -0
  15. package/dist/locale.d.ts.map +1 -0
  16. package/dist/locale.js +55 -0
  17. package/dist/locale.js.map +1 -0
  18. package/dist/marks/atlas-L.svg +3 -0
  19. package/dist/marks/atlas-M.svg +1 -0
  20. package/dist/marks/atlas-S.svg +1 -0
  21. package/dist/marks/austeris-L.svg +6 -0
  22. package/dist/marks/austeris-M.svg +1 -0
  23. package/dist/marks/austeris-S.svg +1 -0
  24. package/dist/marks/dowel-L.svg +5 -0
  25. package/dist/marks/dowel-M.svg +1 -0
  26. package/dist/marks/dowel-S.svg +1 -0
  27. package/dist/marks/efema-L.svg +8 -0
  28. package/dist/marks/efema-M.svg +1 -0
  29. package/dist/marks/efema-S.svg +1 -0
  30. package/dist/marks/furca-L.svg +7 -0
  31. package/dist/marks/furca-M.svg +1 -0
  32. package/dist/marks/furca-S.svg +1 -0
  33. package/dist/marks/hilvan-L.svg +3 -0
  34. package/dist/marks/hilvan-M.svg +1 -0
  35. package/dist/marks/hilvan-S.svg +1 -0
  36. package/dist/marks/kasl-L.svg +4 -0
  37. package/dist/marks/kasl-M.svg +1 -0
  38. package/dist/marks/kasl-S.svg +1 -0
  39. package/dist/marks/kasl-server-L.svg +4 -0
  40. package/dist/marks/kasl-server-M.svg +1 -0
  41. package/dist/marks/kasl-server-S.svg +1 -0
  42. package/dist/marks/kilna-L.svg +3 -0
  43. package/dist/marks/kilna-M.svg +1 -0
  44. package/dist/marks/kilna-S.svg +1 -0
  45. package/dist/marks/lacodda-L.svg +1 -0
  46. package/dist/marks/lacodda-M.svg +1 -0
  47. package/dist/marks/lacodda-S.svg +1 -0
  48. package/dist/marks/lyrid-L.svg +8 -0
  49. package/dist/marks/lyrid-M.svg +1 -0
  50. package/dist/marks/lyrid-S.svg +1 -0
  51. package/dist/marks/lyrn-L.svg +3 -0
  52. package/dist/marks/lyrn-M.svg +1 -0
  53. package/dist/marks/lyrn-S.svg +1 -0
  54. package/dist/marks/midda-L.svg +4 -0
  55. package/dist/marks/midda-M.svg +1 -0
  56. package/dist/marks/midda-S.svg +1 -0
  57. package/dist/marks/nitid-L.svg +4 -0
  58. package/dist/marks/nitid-M.svg +1 -0
  59. package/dist/marks/nitid-S.svg +1 -0
  60. package/dist/marks/nooma-L.svg +12 -0
  61. package/dist/marks/nooma-M.svg +1 -0
  62. package/dist/marks/nooma-S.svg +1 -0
  63. package/dist/marks/rhapsod-L.svg +4 -0
  64. package/dist/marks/rhapsod-M.svg +1 -0
  65. package/dist/marks/rhapsod-S.svg +1 -0
  66. package/dist/marks/rigger-L.svg +8 -0
  67. package/dist/marks/rigger-M.svg +1 -0
  68. package/dist/marks/rigger-S.svg +1 -0
  69. package/dist/marks/scheda-L.svg +5 -0
  70. package/dist/marks/scheda-M.svg +1 -0
  71. package/dist/marks/scheda-S.svg +1 -0
  72. package/dist/marks/sefy-L.svg +4 -0
  73. package/dist/marks/sefy-M.svg +1 -0
  74. package/dist/marks/sefy-S.svg +1 -0
  75. package/dist/marks/turnout-L.svg +4 -0
  76. package/dist/marks/turnout-M.svg +1 -0
  77. package/dist/marks/turnout-S.svg +1 -0
  78. package/dist/marks.d.ts +12 -0
  79. package/dist/marks.d.ts.map +1 -0
  80. package/dist/marks.js +121 -0
  81. package/dist/marks.js.map +1 -0
  82. package/dist/palettes/atlas.json +1 -1
  83. package/dist/palettes/austeris.json +1 -1
  84. package/dist/palettes/dowel.json +1 -1
  85. package/dist/palettes/efema.json +1 -1
  86. package/dist/palettes/furca.json +1 -1
  87. package/dist/palettes/hilvan.json +1 -1
  88. package/dist/palettes/kasl-server.json +1 -1
  89. package/dist/palettes/kasl.json +1 -1
  90. package/dist/palettes/kilna.json +1 -1
  91. package/dist/palettes/lyrid.json +1 -1
  92. package/dist/palettes/lyrn.json +1 -1
  93. package/dist/palettes/midda.json +1 -1
  94. package/dist/palettes/nitid.json +1 -1
  95. package/dist/palettes/nooma.json +1 -1
  96. package/dist/palettes/rhapsod.json +1 -1
  97. package/dist/palettes/rigger.json +1 -1
  98. package/dist/palettes/scheda.json +1 -1
  99. package/dist/palettes/sefy.json +1 -1
  100. package/dist/palettes/turnout.json +1 -1
  101. package/dist/registry.json +310 -93
  102. package/package.json +8 -3
@@ -301,6 +301,47 @@
301
301
  }
302
302
  ]
303
303
  },
304
+ {
305
+ "name": "about-plate",
306
+ "type": "registry:ui",
307
+ "title": "AboutPlate",
308
+ "description": "The about-plate primitive.",
309
+ "dependencies": [
310
+ "dowel-ui@^0.31.0"
311
+ ],
312
+ "registryDependencies": [
313
+ "https://lacodda.github.io/dowel/r/product-mark.json"
314
+ ],
315
+ "files": [
316
+ {
317
+ "path": "ui/about-plate.tsx",
318
+ "target": "@ui/about-plate.tsx",
319
+ "type": "registry:ui",
320
+ "content": "import type { ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport type { MarkName } from 'dowel-ui/marks'\nimport { LineMark, ProductMark } from './product-mark'\n\n/*\n * AboutPlate - what a product says about itself on its About screen, in the\n * same shape across the line.\n *\n * Every desktop product has an About box and every one of them was written\n * from scratch: the mark at whatever size came to hand, the version as a\n * string in the middle of a sentence, a line about the family somewhere or\n * nowhere. Read side by side they looked like products of different makers,\n * which is the one thing a line of products is supposed not to look like.\n *\n * The plate is the mark at its largest level (the one with the metaphor - the\n * About screen is where a person has the time to see it), the name, the\n * version in the monospace face a version is read in, the one-line promise,\n * whatever the product adds (licence, links, a build hash) and, under a rule,\n * the λ tile with the words the product gives it: this product belongs to the\n * lacodda line. The words are the product's, like every other string here.\n */\n\nexport interface AboutPlateProps {\n /** Which product's mark to draw. */\n product: MarkName\n /** The product's name, as it is written. */\n name: string\n /** The version, as the product shows it: \"v0.31.0\". */\n version: string\n /** The one-line promise, as on the product's README. */\n tagline?: string\n /** What the product adds: licence, links, where its data lives. */\n children?: ReactNode\n /** The words beside λ, e.g. \"Part of the lacodda line\". */\n lineLabel: string\n /** Where those words lead, if anywhere. */\n lineHref?: string\n className?: string\n}\n\nexport function AboutPlate({\n product,\n name,\n version,\n tagline,\n children,\n lineLabel,\n lineHref,\n className,\n}: AboutPlateProps) {\n const line = (\n <>\n <LineMark size={16} />\n <span>{lineLabel}</span>\n </>\n )\n\n return (\n <section aria-label={name} className={cn('flex flex-col items-center gap-3 text-center', className)}>\n <ProductMark product={product} size={96} />\n <div className=\"flex flex-col items-center gap-1\">\n <h2 className=\"m-0 text-lg font-semibold text-text\">{name}</h2>\n <span className=\"font-mono text-xs text-dim\">{version}</span>\n </div>\n {tagline && <p className=\"m-0 max-w-prose text-sm text-dim\">{tagline}</p>}\n {children && <div className=\"flex flex-col items-center gap-1 text-xs text-dim\">{children}</div>}\n <div className=\"mt-2 flex w-full justify-center border-t border-line pt-3\">\n {lineHref ? (\n <a\n href={lineHref}\n className={cn(\n 'inline-flex items-center gap-2 rounded-sm text-xs text-dim underline-offset-4 hover:text-text hover:underline',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n {line}\n </a>\n ) : (\n <span className=\"inline-flex items-center gap-2 text-xs text-dim\">{line}</span>\n )}\n </div>\n </section>\n )\n}\n"
321
+ }
322
+ ]
323
+ },
324
+ {
325
+ "name": "accordion",
326
+ "type": "registry:ui",
327
+ "title": "Accordion",
328
+ "description": "A stack of collapsible sections that share one rule about how many can be open at once - an FAQ, the groups in a long settings page, the filters in a sidebar. Each section is `Collapsible`'s shape (a header, a trigger, a panel) repeated inside a group that also decides `single` or `multiple`, which is the one thing a lone `Collapsible` has no opinion about because it has nothing to be exclusive with.",
329
+ "dependencies": [
330
+ "@base-ui/react",
331
+ "dowel-ui@^0.31.0"
332
+ ],
333
+ "registryDependencies": [
334
+ "https://lacodda.github.io/dowel/r/collapsible.json"
335
+ ],
336
+ "files": [
337
+ {
338
+ "path": "ui/accordion.tsx",
339
+ "target": "@ui/accordion.tsx",
340
+ "type": "registry:ui",
341
+ "content": "import { Accordion as Base } from '@base-ui/react/accordion'\nimport { cn } from 'dowel-ui'\nimport { collapsibleTriggerClasses } from './collapsible'\n\n/*\n * Accordion.\n *\n * A stack of collapsible sections that share one rule about how many can be\n * open at once - an FAQ, the groups in a long settings page, the filters in\n * a sidebar. Each section is `Collapsible`'s shape (a header, a trigger, a\n * panel) repeated inside a group that also decides `single` or `multiple`,\n * which is the one thing a lone `Collapsible` has no opinion about because it\n * has nothing to be exclusive with.\n *\n * Base UI ships this as a genuinely separate primitive rather than several\n * `Collapsible`s wrapped in a loop - the group owns the open value and\n * enforces `single` or `multiple` across every item, which a set of\n * independent collapsibles cannot coordinate - so this is its own file. The\n * two still share one thing worth sharing rather than restating:\n * `collapsibleTriggerClasses`, imported from `./collapsible` rather than\n * copied, because a header row and a lone trigger are the same control in two\n * places and two class lists drift.\n *\n * `AccordionTrigger` sits inside `AccordionHeader`, an `<h3>` - the pattern\n * the ARIA accordion spec asks for so a reader moving by heading lands on\n * every section title, open or shut. Skipping the heading and putting the\n * button straight in the item is the commonest hand-rolled accordion bug,\n * and it is invisible until someone navigates by headings and finds none.\n *\n * Each trigger is an ordinary tab stop, moved between with Tab rather than\n * arrow keys - the installed Base UI has dropped the roving-focus pattern\n * for accordion headers, following the ARIA APG's own 2024 guidance update\n * that removed it, so this is current spec behaviour rather than a gap.\n * `TreeView`, by contrast, still owns one cursor for its whole tree, because\n * that update did not touch trees.\n *\n * `multiple` on the root is what decides whether opening one section shuts\n * the others - `false` (the default) for an FAQ where one answer at a time\n * keeps the page short, `true` for a settings page where every group is\n * independent.\n */\n\nexport const accordionTriggerClasses = collapsibleTriggerClasses\n\n/** Groups the items and decides how many can be open. `single` (the\n * default, `multiple={false}`) closes the others when one opens; `multiple`\n * lets any number stay open together. Controlled with `value` and\n * `onValueChange`, or left to manage itself with `defaultValue`. */\nexport const Accordion = Base.Root\n\n/** One section: a header with its trigger, and the panel it opens. */\nexport const AccordionItem = Base.Item\n\n/** The `<h3>` a reader moving by heading lands on, open or shut. The trigger\n * lives inside it, never beside it - a section title that exists only as a\n * button's accessible name is not a heading a reader can navigate to. */\nexport function AccordionHeader({ className, ...props }: Base.Header.Props) {\n return <Base.Header className={cn('text-inherit', className)} {...props} />\n}\n\n/** The button that opens and shuts its section's panel. The chevron rotates\n * with `data-panel-open`, the same attribute a reader's `aria-expanded`\n * follows, so the two can never disagree. */\nexport function AccordionTrigger({ className, children, ...props }: Base.Trigger.Props) {\n return (\n <Base.Trigger className={cn(accordionTriggerClasses, className)} {...props}>\n {children}\n <svg\n viewBox=\"0 0 16 16\"\n aria-hidden\n className=\"size-3.5 shrink-0 text-dim transition-transform duration-quick group-data-[panel-open]:rotate-180\"\n >\n <path\n d=\"M4 6l4 4 4-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.Trigger>\n )\n}\n\n/** The section's content. Animates by its own measured height through\n * `--accordion-panel-height`, exactly as `CollapsiblePanel` does - the same\n * reasoning applies unchanged: a fixed max-height guess is either too small\n * for long content or a visible pause for short content, and the measured\n * variable is neither. */\nexport function AccordionPanel({ className, ...props }: Base.Panel.Props) {\n return (\n <Base.Panel\n className={cn(\n 'h-(--accordion-panel-height) overflow-hidden text-sm text-dim',\n 'transition-[height] duration-base ease-out',\n 'data-[starting-style]:h-0 data-[ending-style]:h-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
342
+ }
343
+ ]
344
+ },
304
345
  {
305
346
  "name": "action-bar",
306
347
  "type": "registry:ui",
@@ -309,7 +350,7 @@
309
350
  "dependencies": [
310
351
  "@base-ui/react",
311
352
  "class-variance-authority",
312
- "dowel-ui@^0.30.0"
353
+ "dowel-ui@^0.31.0"
313
354
  ],
314
355
  "registryDependencies": [],
315
356
  "files": [
@@ -328,7 +369,7 @@
328
369
  "description": "The shape everyone recognises: weeks as columns, weekdays as rows, time running left to right, and a value carried by how dark a square is. Two products of the line asked for it by name before it existed.",
329
370
  "dependencies": [
330
371
  "class-variance-authority",
331
- "dowel-ui@^0.30.0"
372
+ "dowel-ui@^0.31.0"
332
373
  ],
333
374
  "registryDependencies": [
334
375
  "https://lacodda.github.io/dowel/r/activity-weeks.json"
@@ -348,7 +389,7 @@
348
389
  "title": "ActivityLegend",
349
390
  "description": "Separate from the grid because a caller showing three grids on one screen wants one legend, and because the words in it are the product's.",
350
391
  "dependencies": [
351
- "dowel-ui@^0.30.0"
392
+ "dowel-ui@^0.31.0"
352
393
  ],
353
394
  "registryDependencies": [
354
395
  "https://lacodda.github.io/dowel/r/activity-heatmap.json",
@@ -386,7 +427,7 @@
386
427
  "description": "A message that stays on the screen, in the flow of the page, about the thing next to it: this field could not be saved, this profile has no axes yet, this export is out of date.",
387
428
  "dependencies": [
388
429
  "class-variance-authority",
389
- "dowel-ui@^0.30.0"
430
+ "dowel-ui@^0.31.0"
390
431
  ],
391
432
  "registryDependencies": [],
392
433
  "files": [
@@ -405,7 +446,7 @@
405
446
  "description": "The frame a product draws once and then never thinks about: a bar across the top, a rail down the left, and the screen in the corner they leave. It is three boxes and a grid, which is exactly why every product wrote its own - and why every one of them got the same two things wrong before getting them right.",
406
447
  "dependencies": [
407
448
  "class-variance-authority",
408
- "dowel-ui@^0.30.0"
449
+ "dowel-ui@^0.31.0"
409
450
  ],
410
451
  "registryDependencies": [],
411
452
  "files": [
@@ -424,7 +465,7 @@
424
465
  "description": "A person, in the space of a word. Every screen that lists people needs one, and the three things that go wrong with it are always the same:\n * - **The picture fails to load** and a broken-image glyph appears where a face was. The fallback is not a nicety; it is the state this component spends most of its life in, because half the people in any list have no picture at all.",
425
466
  "dependencies": [
426
467
  "class-variance-authority",
427
- "dowel-ui@^0.30.0"
468
+ "dowel-ui@^0.31.0"
428
469
  ],
429
470
  "registryDependencies": [],
430
471
  "files": [
@@ -443,7 +484,7 @@
443
484
  "description": "A small piece of state attached to something else: a count, a status, a label. It is not a button and never was - if it can be clicked it is a Chip.",
444
485
  "dependencies": [
445
486
  "class-variance-authority",
446
- "dowel-ui@^0.30.0"
487
+ "dowel-ui@^0.31.0"
447
488
  ],
448
489
  "registryDependencies": [],
449
490
  "files": [
@@ -462,7 +503,7 @@
462
503
  "description": "A strip across the top of the application, about the application: you are offline, this build is a preview, your licence expires on Friday, a new version is ready to install.",
463
504
  "dependencies": [
464
505
  "class-variance-authority",
465
- "dowel-ui@^0.30.0"
506
+ "dowel-ui@^0.31.0"
466
507
  ],
467
508
  "registryDependencies": [],
468
509
  "files": [
@@ -481,7 +522,7 @@
481
522
  "description": "Bars rather than a line, and the distinction is the data's not the drawing's: a line says the value exists between the points, a column says each period is its own sum. Hours worked in a week is a sum - there is no \"Wednesday afternoon\" reading between two weeks - so it is a column.",
482
523
  "dependencies": [
483
524
  "class-variance-authority",
484
- "dowel-ui@^0.30.0"
525
+ "dowel-ui@^0.31.0"
485
526
  ],
486
527
  "registryDependencies": [],
487
528
  "files": [
@@ -500,7 +541,7 @@
500
541
  "description": "Where you are, as a trail rather than as a word. On a list screen the screen's own name is enough; on something opened from it, it is not - \"Works\" says nothing about which work, and the way back to the list is otherwise the browser's back button alone, which a desktop window does not visibly have.",
501
542
  "dependencies": [
502
543
  "@base-ui/react",
503
- "dowel-ui@^0.30.0"
544
+ "dowel-ui@^0.31.0"
504
545
  ],
505
546
  "registryDependencies": [],
506
547
  "files": [
@@ -520,7 +561,7 @@
520
561
  "dependencies": [
521
562
  "@base-ui/react",
522
563
  "class-variance-authority",
523
- "dowel-ui@^0.30.0"
564
+ "dowel-ui@^0.31.0"
524
565
  ],
525
566
  "registryDependencies": [],
526
567
  "files": [
@@ -544,7 +585,7 @@
544
585
  "path": "ui/calendar-math.tsx",
545
586
  "target": "@ui/calendar-math.tsx",
546
587
  "type": "registry:ui",
547
- "content": "/*\n * The arithmetic a calendar runs on, with no React in it.\n *\n * Split out of the Calendar because the size gate asked the right question:\n * the file was two and a half times over its ceiling, and the reason was that\n * it held two things - the sums, and the grid that draws them. These are the\n * sums, and they are what DatePicker, DateRangePicker and any product doing\n * its own date work import.\n *\n * Everything here takes and returns `YYYY-MM-DD`, and never a `Date`. A\n * birthday has no timezone; a release date has no hour. Put one in a `Date`\n * and it becomes a moment - and moments cross midnight when they are\n * serialised, which is how a date reaches a server a day early. The string is\n * what a database column holds and what JSON carries.\n *\n * `Date` appears inside, in two places only: to ask `Intl` for a name, and to\n * add days. Both are wrapped here, so no caller ever holds one.\n *\n * No date library, deliberately. `react-day-picker` is good and would bring\n * `date-fns` and `@date-fns/tz` behind it - the first heavy dependency in a\n * set that is otherwise Base UI or nothing. `Intl` already knows the part a\n * library would be consulted for: which day the week starts on here, and what\n * the months are called. The rest is the arithmetic below, and it is only\n * hard when a date is stored as a moment.\n */\n\n/** A calendar date: `2026-09-02`. Not a moment - no time, no zone. */\nexport type IsoDate = string\n\n/** Whether a string is a calendar date this component can work with, and one\n * that actually exists. `2026-02-31` parses arithmetically and is not a day. */\nexport function isIsoDate(value: string): value is IsoDate {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n if (month < 1 || month > 12 || day < 1) return false\n return day <= daysInMonth(year, month)\n}\n\n/** How many days that month has. The leap rule in full, because the\n * hundred-year exception is the part that gets left out. */\nexport function daysInMonth(year: number, month: number): number {\n if (month === 2) {\n const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0\n return leap ? 29 : 28\n }\n return [4, 6, 9, 11].includes(month) ? 30 : 31\n}\n\n/** Today, as a calendar date in the reader's own timezone.\n *\n * Deliberately not `new Date().toISOString().slice(0, 10)`, which is the\n * common spelling and is wrong: that converts to UTC first, so anyone east of\n * Greenwich late in the evening gets tomorrow. */\nexport function today(): IsoDate {\n const now = new Date()\n return format(now.getFullYear(), now.getMonth() + 1, now.getDate())\n}\n\nfunction format(year: number, month: number, day: number): IsoDate {\n return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`\n}\n\ninterface Parts {\n year: number\n month: number\n day: number\n}\n\nexport function parts(date: IsoDate): Parts {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return { year, month, day }\n}\n\n/** The same date shifted by whole days. Goes through a `Date` at noon rather\n * than midnight: a shift over a daylight-saving boundary at midnight can land\n * on the same calendar day it started from. */\nexport function addDays(date: IsoDate, days: number): IsoDate {\n const { year, month, day } = parts(date)\n const moved = new Date(year, month - 1, day, 12)\n moved.setDate(moved.getDate() + days)\n return format(moved.getFullYear(), moved.getMonth() + 1, moved.getDate())\n}\n\n/** The same day-of-month in another month, clamped when it does not exist\n * there: a step back from 31 March lands on 28 February, not on 3 March. */\nexport function addMonths(date: IsoDate, months: number): IsoDate {\n const { year, month, day } = parts(date)\n const zero = year * 12 + (month - 1) + months\n const nextYear = Math.floor(zero / 12)\n const nextMonth = (zero % 12) + 1\n return format(nextYear, nextMonth, Math.min(day, daysInMonth(nextYear, nextMonth)))\n}\n\n/** Which weekday a date falls on, as `Intl` numbers them: 1 is Monday, 7 is\n * Sunday. `Date` numbers Sunday 0, which does not sort and does not match\n * what `getWeekInfo` returns. */\nexport function weekday(date: IsoDate): number {\n const { year, month, day } = parts(date)\n const js = new Date(year, month - 1, day, 12).getDay()\n return js === 0 ? 7 : js\n}\n\n/** Which day the week starts on here: 1 Monday, 7 Sunday.\n *\n * `getWeekInfo` is the current spelling and `weekInfo` the older one; some\n * engines have neither, and Monday is the majority answer worldwide. */\nexport function firstDayOfWeek(locale: string | undefined): number {\n try {\n const info = new Intl.Locale(locale ?? navigator.language) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number }\n weekInfo?: { firstDay: number }\n }\n return info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay ?? 1\n } catch {\n return 1\n }\n}\n\n/** The grid of a month: whole weeks, starting on the locale's first day, with\n * the days either side included so every row has seven.\n *\n * Returned as dates rather than as numbers, so a cell never has to be told\n * which month it belongs to - it knows, and a click on a trailing day works\n * without a special case. */\nexport function monthGrid(month: IsoDate, locale?: string): IsoDate[][] {\n const { year, month: monthNumber } = parts(month)\n const first = format(year, monthNumber, 1)\n const start = firstDayOfWeek(locale)\n\n // How far back the grid starts: the distance from the first of the month\n // back to the most recent week start.\n const lead = (weekday(first) - start + 7) % 7\n const origin = addDays(first, -lead)\n\n const weeks: IsoDate[][] = []\n let cursor = origin\n // Six rows always, so the calendar does not change height between months -\n // a popup that resizes as you page through it is one that moves under the\n // pointer.\n for (let week = 0; week < 6; week += 1) {\n const row: IsoDate[] = []\n for (let day = 0; day < 7; day += 1) {\n row.push(cursor)\n cursor = addDays(cursor, 1)\n }\n weeks.push(row)\n }\n return weeks\n}\n\n/** The weekday initials, in the order this locale lays them out. */\nexport function weekdayNames(locale: string | undefined, start: number): string[] {\n const names = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n // Any week works; this one begins on a Monday.\n const monday = Date.UTC(2024, 0, 1)\n return Array.from({ length: 7 }, (_, index) => {\n const offset = (start - 1 + index) % 7\n return names.format(new Date(monday + offset * 86_400_000))\n })\n}\n"
588
+ "content": "/*\n * The arithmetic a calendar runs on, with no React in it.\n *\n * Split out of the Calendar because the size gate asked the right question:\n * the file was two and a half times over its ceiling, and the reason was that\n * it held two things - the sums, and the grid that draws them. These are the\n * sums, and they are what DatePicker, DateRangePicker and any product doing\n * its own date work import.\n *\n * Everything here takes and returns `YYYY-MM-DD`, and never a `Date`. A\n * birthday has no timezone; a release date has no hour. Put one in a `Date`\n * and it becomes a moment - and moments cross midnight when they are\n * serialised, which is how a date reaches a server a day early. The string is\n * what a database column holds and what JSON carries.\n *\n * `Date` appears inside, in two places only: to ask `Intl` for a name, and to\n * add days. Both are wrapped here, so no caller ever holds one.\n *\n * No date library, deliberately. `react-day-picker` is good and would bring\n * `date-fns` and `@date-fns/tz` behind it - the first heavy dependency in a\n * set that is otherwise Base UI or nothing. `Intl` already knows the part a\n * library would be consulted for: which day the week starts on here, and what\n * the months are called. The rest is the arithmetic below, and it is only\n * hard when a date is stored as a moment.\n */\n\n/** A calendar date: `2026-09-02`. Not a moment - no time, no zone. */\nexport type IsoDate = string\n\n/** Whether a string is a calendar date this component can work with, and one\n * that actually exists. `2026-02-31` parses arithmetically and is not a day. */\nexport function isIsoDate(value: string): value is IsoDate {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n if (month < 1 || month > 12 || day < 1) return false\n return day <= daysInMonth(year, month)\n}\n\n/** How many days that month has. The leap rule in full, because the\n * hundred-year exception is the part that gets left out. */\nexport function daysInMonth(year: number, month: number): number {\n if (month === 2) {\n const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0\n return leap ? 29 : 28\n }\n return [4, 6, 9, 11].includes(month) ? 30 : 31\n}\n\n/** Today, as a calendar date in the reader's own timezone.\n *\n * Deliberately not `new Date().toISOString().slice(0, 10)`, which is the\n * common spelling and is wrong: that converts to UTC first, so anyone east of\n * Greenwich late in the evening gets tomorrow. */\nexport function today(): IsoDate {\n const now = new Date()\n return format(now.getFullYear(), now.getMonth() + 1, now.getDate())\n}\n\nfunction format(year: number, month: number, day: number): IsoDate {\n return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`\n}\n\ninterface Parts {\n year: number\n month: number\n day: number\n}\n\nexport function parts(date: IsoDate): Parts {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return { year, month, day }\n}\n\n/** The same date shifted by whole days. Goes through a `Date` at noon rather\n * than midnight: a shift over a daylight-saving boundary at midnight can land\n * on the same calendar day it started from. */\nexport function addDays(date: IsoDate, days: number): IsoDate {\n const { year, month, day } = parts(date)\n const moved = new Date(year, month - 1, day, 12)\n moved.setDate(moved.getDate() + days)\n return format(moved.getFullYear(), moved.getMonth() + 1, moved.getDate())\n}\n\n/** The same day-of-month in another month, clamped when it does not exist\n * there: a step back from 31 March lands on 28 February, not on 3 March. */\nexport function addMonths(date: IsoDate, months: number): IsoDate {\n const { year, month, day } = parts(date)\n const zero = year * 12 + (month - 1) + months\n const nextYear = Math.floor(zero / 12)\n const nextMonth = (zero % 12) + 1\n return format(nextYear, nextMonth, Math.min(day, daysInMonth(nextYear, nextMonth)))\n}\n\n/** Which weekday a date falls on, as `Intl` numbers them: 1 is Monday, 7 is\n * Sunday. `Date` numbers Sunday 0, which does not sort and does not match\n * what `getWeekInfo` returns. */\nexport function weekday(date: IsoDate): number {\n const { year, month, day } = parts(date)\n const js = new Date(year, month - 1, day, 12).getDay()\n return js === 0 ? 7 : js\n}\n\n/** Which day the week starts on here: 1 Monday, 7 Sunday.\n *\n * `getWeekInfo` is the current spelling and `weekInfo` the older one; some\n * engines have neither, and Monday is the majority answer worldwide. */\nexport function firstDayOfWeek(locale: string): number {\n try {\n const info = new Intl.Locale(locale) as Intl.Locale & {\n getWeekInfo?: () => { firstDay: number }\n weekInfo?: { firstDay: number }\n }\n return info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay ?? 1\n } catch {\n return 1\n }\n}\n\n/** The grid of a month: whole weeks, starting on the locale's first day, with\n * the days either side included so every row has seven.\n *\n * Returned as dates rather than as numbers, so a cell never has to be told\n * which month it belongs to - it knows, and a click on a trailing day works\n * without a special case. */\nexport function monthGrid(month: IsoDate, locale: string): IsoDate[][] {\n const { year, month: monthNumber } = parts(month)\n const first = format(year, monthNumber, 1)\n const start = firstDayOfWeek(locale)\n\n // How far back the grid starts: the distance from the first of the month\n // back to the most recent week start.\n const lead = (weekday(first) - start + 7) % 7\n const origin = addDays(first, -lead)\n\n const weeks: IsoDate[][] = []\n let cursor = origin\n // Six rows always, so the calendar does not change height between months -\n // a popup that resizes as you page through it is one that moves under the\n // pointer.\n for (let week = 0; week < 6; week += 1) {\n const row: IsoDate[] = []\n for (let day = 0; day < 7; day += 1) {\n row.push(cursor)\n cursor = addDays(cursor, 1)\n }\n weeks.push(row)\n }\n return weeks\n}\n\n/** The weekday initials, in the order this locale lays them out. */\nexport function weekdayNames(locale: string, start: number): string[] {\n const names = new Intl.DateTimeFormat(locale, { weekday: 'short' })\n // Any week works; this one begins on a Monday.\n const monday = Date.UTC(2024, 0, 1)\n return Array.from({ length: 7 }, (_, index) => {\n const offset = (start - 1 + index) % 7\n return names.format(new Date(monday + offset * 86_400_000))\n })\n}\n"
548
589
  }
549
590
  ]
550
591
  },
@@ -554,7 +595,7 @@
554
595
  "title": "Calendar",
555
596
  "description": "The sums live next door in `calendar-math`, which has no React in it; this is the grid that draws them and the keyboard that moves around it.",
556
597
  "dependencies": [
557
- "dowel-ui@^0.30.0"
598
+ "dowel-ui@^0.31.0"
558
599
  ],
559
600
  "registryDependencies": [
560
601
  "https://lacodda.github.io/dowel/r/calendar-math.json"
@@ -564,7 +605,7 @@
564
605
  "path": "ui/calendar.tsx",
565
606
  "target": "@ui/calendar.tsx",
566
607
  "type": "registry:ui",
567
- "content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn } from 'dowel-ui'\nimport {\n addDays,\n addMonths,\n firstDayOfWeek,\n monthGrid,\n parts,\n today,\n weekday,\n weekdayNames,\n type IsoDate,\n} from './calendar-math'\n\n/*\n * Calendar - a month of days.\n *\n * The sums live next door in `calendar-math`, which has no React in it; this\n * is the grid that draws them and the keyboard that moves around it.\n *\n * One tab stop for the whole grid, arrows within - the arrangement a radio\n * group has, and the reason a calendar is usable at all: forty-two tab stops\n * is not a control. Arrows move a cursor and only Enter chooses, so a product\n * listening for a change does not receive five dates on the way to the sixth.\n */\n\nexport interface CalendarProps {\n /** The selected day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Which month is shown. Uncontrolled unless given. */\n month?: IsoDate\n onMonthChange?: (month: IsoDate) => void\n /** Bounds, inclusive. A day outside them cannot be chosen. */\n min?: IsoDate\n max?: IsoDate\n /** For a range: the other end, so the days between can be shaded. */\n rangeEnd?: IsoDate\n /** Formats the names. Left alone it is the reader's own. */\n locale?: string\n /** What the grid is called, for a screen reader. */\n 'aria-label'?: string\n /** Names the buttons that page the months. Required: they are icons, and an\n * icon with no name is a button that announces nothing. */\n previousMonthLabel: string\n nextMonthLabel: string\n className?: string\n}\n\nexport function Calendar({\n value,\n onValueChange,\n month,\n onMonthChange,\n min,\n max,\n rangeEnd,\n locale,\n previousMonthLabel,\n nextMonthLabel,\n className,\n 'aria-label': ariaLabel,\n}: CalendarProps) {\n const [ownMonth, setOwnMonth] = useState<IsoDate>(() => value ?? today())\n const shown = month ?? ownMonth\n\n /* Which day the keyboard is on. It is not the selection: arrowing around a\n * calendar moves a cursor, and only Enter chooses - otherwise every arrow\n * key would fire `onValueChange` and a product listening for it would save\n * five dates on the way to the sixth. */\n const [focused, setFocused] = useState<IsoDate>(() => value ?? today())\n\n const weeks = useMemo(() => monthGrid(shown, locale), [shown, locale])\n const start = firstDayOfWeek(locale)\n const names = useMemo(() => weekdayNames(locale, start), [locale, start])\n const heading = useMemo(\n () => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(\n new Date(parts(shown).year, parts(shown).month - 1, 1),\n ),\n [shown, locale],\n )\n const dayNumber = useMemo(() => new Intl.DateTimeFormat(locale, { day: 'numeric' }), [locale])\n const fullDate = useMemo(\n () => new Intl.DateTimeFormat(locale, { dateStyle: 'long' }),\n [locale],\n )\n\n const outOfBounds = (date: IsoDate) =>\n (min !== undefined && date < min) || (max !== undefined && date > max)\n\n const goToMonth = (next: IsoDate) => {\n if (month === undefined) setOwnMonth(next)\n onMonthChange?.(next)\n }\n\n const moveFocus = (next: IsoDate) => {\n setFocused(next)\n // Paging follows the cursor: arrowing off the end of a month shows the\n // next one rather than moving to a day nobody can see.\n if (parts(next).month !== parts(shown).month || parts(next).year !== parts(shown).year) {\n goToMonth(next)\n }\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n const jump: Record<string, () => IsoDate> = {\n ArrowRight: () => addDays(focused, 1),\n ArrowLeft: () => addDays(focused, -1),\n ArrowDown: () => addDays(focused, 7),\n ArrowUp: () => addDays(focused, -7),\n PageDown: () => addMonths(focused, 1),\n PageUp: () => addMonths(focused, -1),\n Home: () => addDays(focused, -((weekday(focused) - start + 7) % 7)),\n End: () => addDays(focused, 6 - ((weekday(focused) - start + 7) % 7)),\n }\n\n const move = jump[event.key]\n if (move) {\n event.preventDefault()\n moveFocus(move())\n return\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!outOfBounds(focused)) onValueChange?.(focused)\n }\n }\n\n const now = today()\n\n return (\n <div className={cn('w-64 select-none', className)}>\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <button\n type=\"button\"\n aria-label={previousMonthLabel}\n onClick={() => goToMonth(addMonths(shown, -1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M10 3L5 8l5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n\n {/* The month is announced when it changes, so paging with the arrows\n * says where you have arrived rather than moving silently. */}\n <div aria-live=\"polite\" className=\"text-sm font-medium text-text\">\n {heading}\n </div>\n\n <button\n type=\"button\"\n aria-label={nextMonthLabel}\n onClick={() => goToMonth(addMonths(shown, 1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M6 3l5 5-5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n </div>\n\n {/* One tab stop for the whole grid, and the arrows move within it - the\n * arrangement a radio group has, and the reason a calendar is usable at\n * all: forty-two tab stops is not a control. */}\n <div\n role=\"grid\"\n aria-label={ariaLabel}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cn(\n 'rounded-md',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <div role=\"row\" className=\"mb-1 grid grid-cols-7\">\n {names.map((name) => (\n <div\n key={name}\n role=\"columnheader\"\n aria-label={name}\n className=\"py-1 text-center text-2xs uppercase tracking-caption text-faint\"\n >\n {name}\n </div>\n ))}\n </div>\n\n {weeks.map((week) => (\n <div role=\"row\" key={week[0]} className=\"grid grid-cols-7\">\n {week.map((date) => {\n const outside = parts(date).month !== parts(shown).month\n const disabled = outOfBounds(date)\n const selected =\n value !== undefined &&\n (rangeEnd === undefined\n ? date === value\n : date === value || date === rangeEnd)\n const inRange =\n value !== undefined && rangeEnd !== undefined && date > value && date < rangeEnd\n\n return (\n <div role=\"gridcell\" key={date} aria-selected={selected || undefined}>\n <button\n type=\"button\"\n // Not a tab stop: the grid is the control. Announced with\n // its full date, because \"14\" on its own is not a date.\n tabIndex={-1}\n disabled={disabled}\n aria-label={fullDate.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n aria-current={date === now ? 'date' : undefined}\n onClick={() => {\n setFocused(date)\n if (!disabled) onValueChange?.(date)\n }}\n className={cn(\n 'flex h-8 w-full items-center justify-center rounded-md text-sm tabular-nums',\n 'transition-colors',\n outside ? 'text-faint' : 'text-text',\n inRange && 'bg-accent-soft',\n selected && 'bg-accent font-medium text-on-accent',\n !selected && !disabled && 'hover:bg-soft',\n date === now && !selected && 'font-medium text-accent',\n date === focused && 'ring-1 ring-line-2',\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n {dayNumber.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n </button>\n </div>\n )\n })}\n </div>\n ))}\n </div>\n </div>\n )\n}\n"
608
+ "content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport {\n addDays,\n addMonths,\n firstDayOfWeek,\n monthGrid,\n parts,\n today,\n weekday,\n weekdayNames,\n type IsoDate,\n} from './calendar-math'\n\n/*\n * Calendar - a month of days.\n *\n * The sums live next door in `calendar-math`, which has no React in it; this\n * is the grid that draws them and the keyboard that moves around it.\n *\n * One tab stop for the whole grid, arrows within - the arrangement a radio\n * group has, and the reason a calendar is usable at all: forty-two tab stops\n * is not a control. Arrows move a cursor and only Enter chooses, so a product\n * listening for a change does not receive five dates on the way to the sixth.\n */\n\nexport interface CalendarProps {\n /** The selected day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Which month is shown. Uncontrolled unless given. */\n month?: IsoDate\n onMonthChange?: (month: IsoDate) => void\n /** Bounds, inclusive. A day outside them cannot be chosen. */\n min?: IsoDate\n max?: IsoDate\n /** For a range: the other end, so the days between can be shaded. */\n rangeEnd?: IsoDate\n /** Formats the names. The application's language by default - see `useLocale`. */\n locale?: string\n /** What the grid is called, for a screen reader. */\n 'aria-label'?: string\n /** Names the buttons that page the months. Required: they are icons, and an\n * icon with no name is a button that announces nothing. */\n previousMonthLabel: string\n nextMonthLabel: string\n className?: string\n}\n\nexport function Calendar({\n value,\n onValueChange,\n month,\n onMonthChange,\n min,\n max,\n rangeEnd,\n locale,\n previousMonthLabel,\n nextMonthLabel,\n className,\n 'aria-label': ariaLabel,\n}: CalendarProps) {\n const [ownMonth, setOwnMonth] = useState<IsoDate>(() => value ?? today())\n const shown = month ?? ownMonth\n\n /* Which day the keyboard is on. It is not the selection: arrowing around a\n * calendar moves a cursor, and only Enter chooses - otherwise every arrow\n * key would fire `onValueChange` and a product listening for it would save\n * five dates on the way to the sixth. */\n const [focused, setFocused] = useState<IsoDate>(() => value ?? today())\n\n const language = useLocale(locale)\n const weeks = useMemo(() => monthGrid(shown, language), [shown, language])\n const start = firstDayOfWeek(language)\n const names = useMemo(() => weekdayNames(language, start), [language, start])\n const heading = useMemo(\n () => new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric' }).format(\n new Date(parts(shown).year, parts(shown).month - 1, 1),\n ),\n [shown, language],\n )\n const dayNumber = useMemo(() => new Intl.DateTimeFormat(language, { day: 'numeric' }), [language])\n const fullDate = useMemo(\n () => new Intl.DateTimeFormat(language, { dateStyle: 'long' }),\n [language],\n )\n\n const outOfBounds = (date: IsoDate) =>\n (min !== undefined && date < min) || (max !== undefined && date > max)\n\n const goToMonth = (next: IsoDate) => {\n if (month === undefined) setOwnMonth(next)\n onMonthChange?.(next)\n }\n\n const moveFocus = (next: IsoDate) => {\n setFocused(next)\n // Paging follows the cursor: arrowing off the end of a month shows the\n // next one rather than moving to a day nobody can see.\n if (parts(next).month !== parts(shown).month || parts(next).year !== parts(shown).year) {\n goToMonth(next)\n }\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n const jump: Record<string, () => IsoDate> = {\n ArrowRight: () => addDays(focused, 1),\n ArrowLeft: () => addDays(focused, -1),\n ArrowDown: () => addDays(focused, 7),\n ArrowUp: () => addDays(focused, -7),\n PageDown: () => addMonths(focused, 1),\n PageUp: () => addMonths(focused, -1),\n Home: () => addDays(focused, -((weekday(focused) - start + 7) % 7)),\n End: () => addDays(focused, 6 - ((weekday(focused) - start + 7) % 7)),\n }\n\n const move = jump[event.key]\n if (move) {\n event.preventDefault()\n moveFocus(move())\n return\n }\n\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (!outOfBounds(focused)) onValueChange?.(focused)\n }\n }\n\n const now = today()\n\n return (\n <div className={cn('w-64 select-none', className)}>\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <button\n type=\"button\"\n aria-label={previousMonthLabel}\n onClick={() => goToMonth(addMonths(shown, -1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M10 3L5 8l5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n\n {/* The month is announced when it changes, so paging with the arrows\n * says where you have arrived rather than moving silently. */}\n <div aria-live=\"polite\" className=\"text-sm font-medium text-text\">\n {heading}\n </div>\n\n <button\n type=\"button\"\n aria-label={nextMonthLabel}\n onClick={() => goToMonth(addMonths(shown, 1))}\n className={cn(\n 'flex size-7 items-center justify-center rounded-md text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n <path\n d=\"M6 3l5 5-5 5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.75\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </button>\n </div>\n\n {/* One tab stop for the whole grid, and the arrows move within it - the\n * arrangement a radio group has, and the reason a calendar is usable at\n * all: forty-two tab stops is not a control. */}\n <div\n role=\"grid\"\n aria-label={ariaLabel}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cn(\n 'rounded-md',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <div role=\"row\" className=\"mb-1 grid grid-cols-7\">\n {names.map((name) => (\n <div\n key={name}\n role=\"columnheader\"\n aria-label={name}\n className=\"py-1 text-center text-2xs uppercase tracking-caption text-faint\"\n >\n {name}\n </div>\n ))}\n </div>\n\n {weeks.map((week) => (\n <div role=\"row\" key={week[0]} className=\"grid grid-cols-7\">\n {week.map((date) => {\n const outside = parts(date).month !== parts(shown).month\n const disabled = outOfBounds(date)\n const selected =\n value !== undefined &&\n (rangeEnd === undefined\n ? date === value\n : date === value || date === rangeEnd)\n const inRange =\n value !== undefined && rangeEnd !== undefined && date > value && date < rangeEnd\n\n return (\n <div role=\"gridcell\" key={date} aria-selected={selected || undefined}>\n <button\n type=\"button\"\n // Not a tab stop: the grid is the control. Announced with\n // its full date, because \"14\" on its own is not a date.\n tabIndex={-1}\n disabled={disabled}\n aria-label={fullDate.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n aria-current={date === now ? 'date' : undefined}\n onClick={() => {\n setFocused(date)\n if (!disabled) onValueChange?.(date)\n }}\n className={cn(\n 'flex h-8 w-full items-center justify-center rounded-md text-sm tabular-nums',\n 'transition-colors',\n outside ? 'text-faint' : 'text-text',\n inRange && 'bg-accent-soft',\n selected && 'bg-accent font-medium text-on-accent',\n !selected && !disabled && 'hover:bg-soft',\n date === now && !selected && 'font-medium text-accent',\n date === focused && 'ring-1 ring-line-2',\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n {dayNumber.format(new Date(parts(date).year, parts(date).month - 1, parts(date).day))}\n </button>\n </div>\n )\n })}\n </div>\n ))}\n </div>\n </div>\n )\n}\n"
568
609
  }
569
610
  ]
570
611
  },
@@ -575,7 +616,7 @@
575
616
  "description": "The interesting part is the words. A checkbox on its own is a nine-pixel target that says nothing; wired to a label it is the whole row, and the row is what a finger and a pointer both aim at. So the label is part of the component rather than something a caller remembers to add - the commonest bug in a hand-rolled checkbox is a `<label>` that is next to the input instead of tied to it, which looks identical and does nothing.",
576
617
  "dependencies": [
577
618
  "@base-ui/react",
578
- "dowel-ui@^0.30.0"
619
+ "dowel-ui@^0.31.0"
579
620
  ],
580
621
  "registryDependencies": [],
581
622
  "files": [
@@ -594,7 +635,7 @@
594
635
  "description": "A badge you can act on: a filter that can be removed, a tag with a count, a selected value in a field. The difference from a Badge is entirely about whether something happens when you click it - and if something does, that part is a real `<button>` with a real label, not a decorative cross.",
595
636
  "dependencies": [
596
637
  "class-variance-authority",
597
- "dowel-ui@^0.30.0"
638
+ "dowel-ui@^0.31.0"
598
639
  ],
599
640
  "registryDependencies": [],
600
641
  "files": [
@@ -613,7 +654,7 @@
613
654
  "description": "The frame around a piece of code is the same everywhere and is written again in every product: the scroll that must not wrap, the gutter of line numbers that must not be selectable, the copy button, the caption saying which file this is, and the marking of the lines the reader was sent here to look at.",
614
655
  "dependencies": [
615
656
  "class-variance-authority",
616
- "dowel-ui@^0.30.0"
657
+ "dowel-ui@^0.31.0"
617
658
  ],
618
659
  "registryDependencies": [
619
660
  "https://lacodda.github.io/dowel/r/copy-button.json"
@@ -627,13 +668,32 @@
627
668
  }
628
669
  ]
629
670
  },
671
+ {
672
+ "name": "collapsible",
673
+ "type": "registry:ui",
674
+ "title": "Collapsible",
675
+ "description": "One region of a screen that a reader shows or hides by choice - release notes under a version number, a filter panel, the raw payload under a log line. It differs from Accordion in the way a single light switch differs from a panel of them: there is one trigger and one panel, no group holding several and no rule about how many can be open, because there is only ever one to be open or not.",
676
+ "dependencies": [
677
+ "@base-ui/react",
678
+ "dowel-ui@^0.31.0"
679
+ ],
680
+ "registryDependencies": [],
681
+ "files": [
682
+ {
683
+ "path": "ui/collapsible.tsx",
684
+ "target": "@ui/collapsible.tsx",
685
+ "type": "registry:ui",
686
+ "content": "import { Collapsible as Base } from '@base-ui/react/collapsible'\nimport { cn } from 'dowel-ui'\n\n/*\n * Collapsible.\n *\n * One region of a screen that a reader shows or hides by choice - release\n * notes under a version number, a filter panel, the raw payload under a log\n * line. It differs from Accordion in the way a single light switch differs\n * from a panel of them: there is one trigger and one panel, no group holding\n * several and no rule about how many can be open, because there is only ever\n * one to be open or not.\n *\n * The trigger is a real `<button>` wired to the panel by `aria-controls` and\n * reports `aria-expanded`, which is what lets a reader who cannot see the\n * panel appear still know it did. `Base.Root`'s own `disabled` state reaches\n * both without the caller repeating it on each one.\n *\n * The panel's open and close are driven by height, not by `display` or a\n * fixed max-height guess. Base UI measures the content and publishes it as\n * `--collapsible-panel-height`; the panel transitions `height` between `0`\n * and that variable, so content of any length animates open and shut by its\n * own real size rather than a number picked to be \"big enough\". The theme's\n * own `prefers-reduced-motion` rule cuts every `transition-duration` to\n * near-zero globally, so nothing extra is wired up here for it - the panel\n * still opens and closes, just without the motion.\n *\n * `keepMounted` is left to the caller by not being reachable at all: this\n * primitive always keeps the panel in the DOM (Base UI's default) rather than\n * unmounting closed content, because a collapsible whose content vanishes on\n * close cannot be found by the browser's own page search, and a product that\n * genuinely wants closed content gone can drop `CollapsiblePanel` from the\n * tree itself.\n */\n\nexport const collapsibleTriggerClasses = cn(\n // `group` is what the chevron below hangs its rotation off: the state\n // (`data-panel-open`) lands on this element, not on the svg inside it.\n 'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-md py-2 text-left text-sm font-medium text-text',\n 'outline-none transition-colors duration-quick',\n 'hover:text-accent',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n)\n\n/** Groups the trigger and the panel. Controlled with `open` and\n * `onOpenChange`, or left to manage itself with `defaultOpen`. */\nexport const Collapsible = Base.Root\n\n/** The button that opens and shuts the panel. The chevron rotates with\n * `data-panel-open`, the same attribute a reader's `aria-expanded` follows,\n * so the two can never say different things. */\nexport function CollapsibleTrigger({ className, children, ...props }: Base.Trigger.Props) {\n return (\n <Base.Trigger className={cn(collapsibleTriggerClasses, className)} {...props}>\n {children}\n <svg\n viewBox=\"0 0 16 16\"\n aria-hidden\n className=\"size-3.5 shrink-0 text-dim transition-transform duration-quick group-data-[panel-open]:rotate-180\"\n >\n <path\n d=\"M4 6l4 4 4-4\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.Trigger>\n )\n}\n\n/** The region that opens and shuts. Animates by its own measured height\n * through `--collapsible-panel-height`, so content of any length - one line\n * or a page of it - opens by its real size rather than a guessed maximum. */\nexport function CollapsiblePanel({ className, ...props }: Base.Panel.Props) {\n return (\n <Base.Panel\n className={cn(\n 'h-(--collapsible-panel-height) overflow-hidden text-sm text-dim',\n 'transition-[height] duration-base ease-out',\n 'data-[starting-style]:h-0 data-[ending-style]:h-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
687
+ }
688
+ ]
689
+ },
630
690
  {
631
691
  "name": "color-field",
632
692
  "type": "registry:ui",
633
693
  "title": "ColorField",
634
694
  "description": "Picking a colour for something the product stores: a tag, a project, a calendar. Note what that is *not* - it is not choosing the appearance of the interface. The theme decides that, from one accent, and a field that let a reader repaint the chrome would undo the argument the whole system rests on.",
635
695
  "dependencies": [
636
- "dowel-ui@^0.30.0"
696
+ "dowel-ui@^0.31.0"
637
697
  ],
638
698
  "registryDependencies": [
639
699
  "https://lacodda.github.io/dowel/r/input.json"
@@ -653,7 +713,7 @@
653
713
  "title": "ColumnResizeHandle",
654
714
  "description": "The handle, and the hook that keeps the widths it produces. Pointer events rather than HTML5 drag-and-drop: a desktop shell that takes file drops for itself never lets a `dragstart` reach the page, so the native API is a handle that does nothing there; pointer capture on the handle also keeps the drag alive when the pointer runs ahead of the cell, which at any speed above a crawl it does.",
655
715
  "dependencies": [
656
- "dowel-ui@^0.30.0"
716
+ "dowel-ui@^0.31.0"
657
717
  ],
658
718
  "registryDependencies": [],
659
719
  "files": [
@@ -673,7 +733,7 @@
673
733
  "dependencies": [
674
734
  "@base-ui/react",
675
735
  "class-variance-authority",
676
- "dowel-ui@^0.30.0"
736
+ "dowel-ui@^0.31.0"
677
737
  ],
678
738
  "registryDependencies": [
679
739
  "https://lacodda.github.io/dowel/r/input.json",
@@ -696,7 +756,7 @@
696
756
  "dependencies": [
697
757
  "@base-ui/react",
698
758
  "class-variance-authority",
699
- "dowel-ui@^0.30.0"
759
+ "dowel-ui@^0.31.0"
700
760
  ],
701
761
  "registryDependencies": [
702
762
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -719,7 +779,7 @@
719
779
  "dependencies": [
720
780
  "@base-ui/react",
721
781
  "class-variance-authority",
722
- "dowel-ui@^0.30.0"
782
+ "dowel-ui@^0.31.0"
723
783
  ],
724
784
  "registryDependencies": [],
725
785
  "files": [
@@ -738,7 +798,7 @@
738
798
  "description": "The same list of actions as Menu, opened the other way round: by right click, or by a long press on a touch screen, over an *area* rather than from a button. So the trigger is not a control - it is the region the menu belongs to, a row, a canvas, a file tile - and it renders a `<div>`.",
739
799
  "dependencies": [
740
800
  "@base-ui/react",
741
- "dowel-ui@^0.30.0"
801
+ "dowel-ui@^0.31.0"
742
802
  ],
743
803
  "registryDependencies": [
744
804
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -758,7 +818,7 @@
758
818
  "title": "CopyButton",
759
819
  "description": "Whatever a product shows in a panel - code, a payload, a log, one side of a comparison - somebody eventually wants to take it away, and the button that lets them is written again every time with the same three things missed.",
760
820
  "dependencies": [
761
- "dowel-ui@^0.30.0"
821
+ "dowel-ui@^0.31.0"
762
822
  ],
763
823
  "registryDependencies": [],
764
824
  "files": [
@@ -776,7 +836,7 @@
776
836
  "title": "Copyable",
777
837
  "description": "Any text that someone will eventually want to copy - an id, a path, a hash, a token - copied with one click. The rule comes from nitid: if a value is worth showing, it is worth being able to take away, and selecting a monospaced id by hand is a small daily tax.",
778
838
  "dependencies": [
779
- "dowel-ui@^0.30.0"
839
+ "dowel-ui@^0.31.0"
780
840
  ],
781
841
  "registryDependencies": [],
782
842
  "files": [
@@ -794,7 +854,7 @@
794
854
  "title": "DatePicker",
795
855
  "description": "The trigger is a button rather than a text input, and that is the decision worth stating. A typable date field has to answer \"what does `03/04/26` mean\" in a locale it cannot be sure of, and it answers wrong for half the world; a button showing the date spelled out has no such question. Where typing genuinely matters - a birth date, forty years back - the calendar is the wrong control anyway and a product should reach for a plain field.",
796
856
  "dependencies": [
797
- "dowel-ui@^0.30.0"
857
+ "dowel-ui@^0.31.0"
798
858
  ],
799
859
  "registryDependencies": [
800
860
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -807,7 +867,7 @@
807
867
  "path": "ui/date-picker.tsx",
808
868
  "target": "@ui/date-picker.tsx",
809
869
  "type": "registry:ui",
810
- "content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The reader's own\n * unless stated. */\n locale?: string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, locale])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n {/* The value also goes into a form, because a button is not a field and\n * a form submitting the screen would otherwise lose the date. */}\n {name !== undefined && <input type=\"hidden\" name={name} value={value ?? ''} />}\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={value}\n min={min}\n max={max}\n locale={locale}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={(next) => {\n onValueChange?.(next)\n // Choosing a day is the whole errand: the popup closes rather\n // than waiting for a second dismissing click.\n setOpen(false)\n }}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
870
+ "content": "import { useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The\n * application's language unless stated - see `useLocale`. */\n locale?: string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, language])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n {/* The value also goes into a form, because a button is not a field and\n * a form submitting the screen would otherwise lose the date. */}\n {name !== undefined && <input type=\"hidden\" name={name} value={value ?? ''} />}\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={value}\n min={min}\n max={max}\n locale={language}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={(next) => {\n onValueChange?.(next)\n // Choosing a day is the whole errand: the popup closes rather\n // than waiting for a second dismissing click.\n setOpen(false)\n }}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
811
871
  }
812
872
  ]
813
873
  },
@@ -817,7 +877,7 @@
817
877
  "title": "DateRangePicker",
818
878
  "description": "The interesting part is the state between them. After the first click there is a start and no end, and that is not an incomplete range to be hidden or a range of one day - it is the normal middle of the interaction, and the calendar has to show it: the first day marked, the days under the pointer shading as the reader moves, the popup staying open. Products that skip it end up with a picker that seems to do nothing until the second click.",
819
879
  "dependencies": [
820
- "dowel-ui@^0.30.0"
880
+ "dowel-ui@^0.31.0"
821
881
  ],
822
882
  "registryDependencies": [
823
883
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -830,7 +890,7 @@
830
890
  "path": "ui/date-range-picker.tsx",
831
891
  "target": "@ui/date-range-picker.tsx",
832
892
  "type": "registry:ui",
833
- "content": "import { useMemo, useState } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n 'aria-label'?: string\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, locale])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={range.start}\n rangeEnd={range.end}\n min={min}\n max={max}\n locale={locale}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={choose}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
893
+ "content": "import { useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n 'aria-label'?: string\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, language])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-faint')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={range.start}\n rangeEnd={range.end}\n min={min}\n max={max}\n locale={language}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={choose}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
834
894
  }
835
895
  ]
836
896
  },
@@ -842,7 +902,7 @@
842
902
  "dependencies": [
843
903
  "@base-ui/react",
844
904
  "class-variance-authority",
845
- "dowel-ui@^0.30.0"
905
+ "dowel-ui@^0.31.0"
846
906
  ],
847
907
  "registryDependencies": [],
848
908
  "files": [
@@ -877,7 +937,7 @@
877
937
  "description": "The question this answers is \"how did this read before, and how does it read now\" - a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is no staging, no comment, nothing to accept. It is for looking.",
878
938
  "dependencies": [
879
939
  "class-variance-authority",
880
- "dowel-ui@^0.30.0"
940
+ "dowel-ui@^0.31.0"
881
941
  ],
882
942
  "registryDependencies": [
883
943
  "https://lacodda.github.io/dowel/r/copy-button.json",
@@ -899,7 +959,7 @@
899
959
  "description": "The line between one part of a screen and the next. The set already had four of them - in the menu, the select, the context menu and the action bar - and each was written inside the thing it divided, so a screen that wanted a rule between two sections had nothing and reached for a bare `<hr>` or a `div` with a background.",
900
960
  "dependencies": [
901
961
  "class-variance-authority",
902
- "dowel-ui@^0.30.0"
962
+ "dowel-ui@^0.31.0"
903
963
  ],
904
964
  "registryDependencies": [],
905
965
  "files": [
@@ -919,7 +979,7 @@
919
979
  "dependencies": [
920
980
  "@base-ui/react",
921
981
  "class-variance-authority",
922
- "dowel-ui@^0.30.0"
982
+ "dowel-ui@^0.31.0"
923
983
  ],
924
984
  "registryDependencies": [],
925
985
  "files": [
@@ -937,7 +997,7 @@
937
997
  "title": "DurationField",
938
998
  "description": "The alternative is what products keep building: two number boxes labelled \"hours\" and \"minutes\", which means two tab stops, two validations, and a reader who has to divide 90 minutes in their head before typing. Here they write `1h 30m`, or `90m`, or `1.5h`, and it means the same thing.",
939
999
  "dependencies": [
940
- "dowel-ui@^0.30.0"
1000
+ "dowel-ui@^0.31.0"
941
1001
  ],
942
1002
  "registryDependencies": [
943
1003
  "https://lacodda.github.io/dowel/r/input.json"
@@ -958,7 +1018,7 @@
958
1018
  "description": "Three kinds of nothing, and a product that draws the same panel for all three is telling the reader the wrong thing twice:\n * **empty** - there is nothing here yet, and that is normal. The panel says what would be here and offers the one action that makes it appear.",
959
1019
  "dependencies": [
960
1020
  "class-variance-authority",
961
- "dowel-ui@^0.30.0"
1021
+ "dowel-ui@^0.31.0"
962
1022
  ],
963
1023
  "registryDependencies": [],
964
1024
  "files": [
@@ -996,7 +1056,7 @@
996
1056
  "description": "Every form is the same four parts repeated: a name for the control, the control, sometimes a hint, and sometimes an error. Written by hand each time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no screen reader mentions, the error appears in red and is announced by nothing at all. This is that arrangement, once.",
997
1057
  "dependencies": [
998
1058
  "@base-ui/react",
999
- "dowel-ui@^0.30.0"
1059
+ "dowel-ui@^0.31.0"
1000
1060
  ],
1001
1061
  "registryDependencies": [],
1002
1062
  "files": [
@@ -1014,7 +1074,7 @@
1014
1074
  "title": "FileDrop",
1015
1075
  "description": "A place to put files: drag them onto it, or press it and pick them. It takes files and hands them over - it does not upload them. Where they go, with which credentials, retried how - that is the product's transport, and a primitive that owned it would be wrong for every product whose upload does not look like the one it guessed.",
1016
1076
  "dependencies": [
1017
- "dowel-ui@^0.30.0"
1077
+ "dowel-ui@^0.31.0"
1018
1078
  ],
1019
1079
  "registryDependencies": [],
1020
1080
  "files": [
@@ -1032,7 +1092,7 @@
1032
1092
  "title": "FilterPopover",
1033
1093
  "description": "A text box, a handful of checkboxes - with one way to clear it. The shell only: what goes in the panel is the caller's, since a stage is ticked and a title is typed and the popover has no opinion.",
1034
1094
  "dependencies": [
1035
- "dowel-ui@^0.30.0"
1095
+ "dowel-ui@^0.31.0"
1036
1096
  ],
1037
1097
  "registryDependencies": [
1038
1098
  "https://lacodda.github.io/dowel/r/button.json",
@@ -1047,13 +1107,32 @@
1047
1107
  }
1048
1108
  ]
1049
1109
  },
1110
+ {
1111
+ "name": "image",
1112
+ "type": "registry:ui",
1113
+ "title": "Image",
1114
+ "description": "The layout jump a loading picture leaves behind is not a styling defect, it is a missing number: the browser has nowhere to put the box until the file arrives and tells it the pixel size, so everything below the picture slides down and back up. `AspectRatio` is that number, given up front by the caller rather than discovered by the browser - a plain box on CSS `aspect-ratio` that reserves its space the instant it is in the tree, and lets any child (an `<img>`, a map, a video) fill it with `size-full`.",
1115
+ "dependencies": [
1116
+ "class-variance-authority",
1117
+ "dowel-ui@^0.31.0"
1118
+ ],
1119
+ "registryDependencies": [],
1120
+ "files": [
1121
+ {
1122
+ "path": "ui/image.tsx",
1123
+ "target": "@ui/image.tsx",
1124
+ "type": "registry:ui",
1125
+ "content": "import {\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ImgHTMLAttributes,\n type ReactNode,\n} from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * AspectRatio and Image.\n *\n * The layout jump a loading picture leaves behind is not a styling defect,\n * it is a missing number: the browser has nowhere to put the box until the\n * file arrives and tells it the pixel size, so everything below the picture\n * slides down and back up. `AspectRatio` is that number, given up front by\n * the caller rather than discovered by the browser - a plain box on CSS\n * `aspect-ratio` that reserves its space the instant it is in the tree, and\n * lets any child (an `<img>`, a map, a video) fill it with `size-full`.\n *\n * `Image` is what goes inside one. It carries its own three-state life, the\n * same shape `Avatar` already uses for its picture - loading, loaded, and\n * failed - because a face and a photograph fail to load for the same\n * reasons and should not fail differently:\n *\n * - **Loading** shows a placeholder in the exact box the ratio already\n * reserved, built from `Skeleton`'s own look (`animate-pulse bg-soft`)\n * rather than a new grey rectangle invented for this one component - one\n * loading language for the set, not two that drift apart over time.\n * - **Loaded** fades the picture in over `--duration-base` rather than\n * popping it in, because a sudden picture reads as a flash the eye has to\n * catch; the theme's own reduced-motion media query cuts every duration to\n * near zero for a reader who asked for that, so nothing extra is wired up\n * here for it. The `<img>` itself is always in the DOM - hidden with\n * opacity and `aria-hidden`, not unmounted - so the load event has\n * something to fire on the first render rather than never.\n * - **Failed** shows `fallback` instead of the browser's own broken-image\n * glyph, which looks like the product forgot the picture rather than that\n * the network did.\n *\n * The state a naive version gets wrong is the fourth one: a picture already\n * in the browser's cache is already `complete` the instant the `<img>` mounts,\n * and no `load` event follows - there is nothing left to load. Without the\n * check in the effect below, that picture's placeholder would sit forever,\n * because the one event this component was waiting for already happened\n * before it started listening. The same effect is what makes navigating back\n * to an already-seen picture look instant instead of re-showing a skeleton\n * for a picture that is sitting in memory.\n *\n * Base UI has no image-loading primitive of its own to reach for here - its\n * `Avatar` component is a separate package this workspace does not install,\n * and this line's own `Avatar` already reimplements the same loaded/failed\n * state by hand for the same reason: a picture is common enough, and small\n * enough, that a dependency would cost more than the `useState` it replaces.\n * `Image` follows that precedent rather than inventing a second one.\n */\n\nexport const aspectRatioVariants = cva('relative w-full overflow-hidden bg-soft', {\n variants: {\n /** How the box is shaped. `square` and `video` are named because those\n * are the two ratios a product reaches for by name; anything else is a\n * plain number on `ratio`. */\n ratio: {\n square: 'aspect-square',\n video: 'aspect-video',\n },\n },\n})\n\n/* `ratio` carries both the named steps and a raw number, which `cva`'s own\n * variant type cannot express - its keys are strings, not numbers. The prop\n * is widened here and split apart in the component instead. */\ntype AspectRatioVariant = VariantProps<typeof aspectRatioVariants>['ratio'] | number\n\nexport interface AspectRatioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The box's shape: `square`, `video`, or a raw number as width / height -\n * `4 / 3`, `21 / 9` - for anything the named steps do not cover. */\n ratio?: AspectRatioVariant\n children?: ReactNode\n}\n\n/** A box that keeps its shape before, during and after whatever fills it\n * loads. `size-full` on the child is the contract: this component reserves\n * the rectangle, the child covers it. */\nexport function AspectRatio({ ratio, className, children, style, ...props }: AspectRatioProps) {\n const named = typeof ratio === 'number' ? undefined : ratio\n const numeric = typeof ratio === 'number' ? ratio : undefined\n\n return (\n <div\n className={cn(aspectRatioVariants({ ratio: named }), className)}\n style={numeric ? { aspectRatio: numeric, ...style } : style}\n {...props}\n >\n {children}\n </div>\n )\n}\n\nexport const imageVariants = cva('size-full', {\n variants: {\n /** How the picture fills a box that is not its own shape. `cover` for a\n * photo standing in for the whole box; `contain` for a logo or a\n * diagram where cropping would cut off the point of it. */\n fit: {\n cover: 'object-cover',\n contain: 'object-contain',\n },\n },\n defaultVariants: { fit: 'cover' },\n})\n\nexport interface ImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'onLoad' | 'onError' | 'placeholder'>,\n VariantProps<typeof imageVariants> {\n /** Required, with no default: a decorative picture says so explicitly with\n * `alt=\"\"` rather than by omission, which is indistinguishable from a\n * picture nobody described yet. */\n alt: string\n /** What stands in the box when the picture fails to load - the product's\n * text or icon, not a default this component would have to write in some\n * one language. */\n fallback?: ReactNode\n}\n\n/** A picture that reserves its box, shows `Skeleton`'s placeholder while it\n * loads, fades in once it has, and shows `fallback` instead of a broken-image\n * glyph if it can't be loaded at all. */\nexport function Image({ fallback, fit, className, src, alt, ...props }: ImageProps) {\n const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading')\n const ref = useRef<HTMLImageElement>(null)\n\n // A picture already in the browser's cache is already `complete` the\n // instant this mounts, and fires no `load` event of its own - there is\n // nothing left to load. Without this check the placeholder above it would\n // never be told to leave. `useLayoutEffect` rather than `useEffect`, so a\n // cached picture never paints its placeholder for even one frame - only\n // the layout-timed effect runs before the browser has painted.\n useLayoutEffect(() => {\n const img = ref.current\n setStatus(img && img.complete && img.naturalWidth > 0 ? 'loaded' : 'loading')\n }, [src])\n\n return (\n <span className=\"relative block size-full overflow-hidden\">\n {status !== 'failed' && (\n <img\n {...props}\n ref={ref}\n src={src}\n alt={alt}\n onLoad={() => setStatus('loaded')}\n onError={() => setStatus('failed')}\n className={cn(\n imageVariants({ fit }),\n 'absolute inset-0 transition-opacity duration-base ease-out',\n status === 'loaded' ? 'opacity-100' : 'opacity-0',\n className,\n )}\n // Hidden from a reader until there is a picture to announce - an\n // `alt` describing content that is not there yet, or never\n // arrives, is worse than saying nothing.\n aria-hidden={status !== 'loaded'}\n />\n )}\n {status === 'loading' && (\n <span aria-hidden className=\"absolute inset-0 animate-pulse rounded-md bg-soft\" />\n )}\n {status === 'failed' && fallback && (\n <span className=\"absolute inset-0 flex items-center justify-center text-dim\">{fallback}</span>\n )}\n </span>\n )\n}\n"
1126
+ }
1127
+ ]
1128
+ },
1050
1129
  {
1051
1130
  "name": "input",
1052
1131
  "type": "registry:ui",
1053
1132
  "title": "Input",
1054
1133
  "description": "A single-line field. It is a plain `<input>` with the line's clothes on, so everything a browser gives an input for free - autofill, spellcheck, the right keyboard on a phone, `type=\"email\"` validation - still works.",
1055
1134
  "dependencies": [
1056
- "dowel-ui@^0.30.0"
1135
+ "dowel-ui@^0.31.0"
1057
1136
  ],
1058
1137
  "registryDependencies": [],
1059
1138
  "files": [
@@ -1087,7 +1166,7 @@
1087
1166
  "title": "JsonViewer",
1088
1167
  "description": "What a product reaches for when it has to show a response, a settings file, a webhook payload - data the reader needs to understand, not edit. The alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.",
1089
1168
  "dependencies": [
1090
- "dowel-ui@^0.30.0"
1169
+ "dowel-ui@^0.31.0"
1091
1170
  ],
1092
1171
  "registryDependencies": [
1093
1172
  "https://lacodda.github.io/dowel/r/json-rows.json"
@@ -1107,7 +1186,7 @@
1107
1186
  "title": "Kbd",
1108
1187
  "description": "A key, as printed in a menu or a hint: `Ctrl` `K`. It is a `<kbd>` element because that is what the element is for - a screen reader announces it as keyboard input rather than reading a stray capital letter.",
1109
1188
  "dependencies": [
1110
- "dowel-ui@^0.30.0"
1189
+ "dowel-ui@^0.31.0"
1111
1190
  ],
1112
1191
  "registryDependencies": [],
1113
1192
  "files": [
@@ -1126,7 +1205,7 @@
1126
1205
  "description": "The shape every product builds out of two `<div>`s in a flex row, and the reason it is worth having once: it is a `<dl>`, and the pairing is what a screen reader announces. Two divs read as four unrelated pieces of text - \"Created\", \"2 hours ago\", \"Owner\", \"Ines\" - and nothing says which value belongs to which name. The right element says it for free.",
1127
1206
  "dependencies": [
1128
1207
  "class-variance-authority",
1129
- "dowel-ui@^0.30.0"
1208
+ "dowel-ui@^0.31.0"
1130
1209
  ],
1131
1210
  "registryDependencies": [],
1132
1211
  "files": [
@@ -1145,7 +1224,7 @@
1145
1224
  "description": "The distinction against its neighbours is the data's, not the drawing's. A column says each period is its own sum - hours worked in a week, and there is no Wednesday-afternoon figure between two weeks. A line says the value existed the whole time and was sampled: an account balance, a price, a temperature. Drawing a sum as a line claims readings nobody took; drawing a level as columns throws away the thing being watched.",
1146
1225
  "dependencies": [
1147
1226
  "class-variance-authority",
1148
- "dowel-ui@^0.30.0"
1227
+ "dowel-ui@^0.31.0"
1149
1228
  ],
1150
1229
  "registryDependencies": [
1151
1230
  "https://lacodda.github.io/dowel/r/line-scale.json"
@@ -1181,7 +1260,7 @@
1181
1260
  "title": "MarkedText",
1182
1261
  "description": "A textarea cannot colour a word. The way round it is older than React: draw the same text twice, once as marked-up HTML underneath and once as the textarea on top with its own text transparent, so the caret and the selection are the browser's and the colours are ours. The two have to agree on every metric - font, size, line height, padding, wrapping - or the marks slide off the words they mark. So both take ONE class list, given by the caller, and the textarea adds only what makes it invisible.",
1183
1262
  "dependencies": [
1184
- "dowel-ui@^0.30.0"
1263
+ "dowel-ui@^0.31.0"
1185
1264
  ],
1186
1265
  "registryDependencies": [],
1187
1266
  "files": [
@@ -1201,7 +1280,7 @@
1201
1280
  "dependencies": [
1202
1281
  "@base-ui/react",
1203
1282
  "class-variance-authority",
1204
- "dowel-ui@^0.30.0"
1283
+ "dowel-ui@^0.31.0"
1205
1284
  ],
1206
1285
  "registryDependencies": [],
1207
1286
  "files": [
@@ -1221,7 +1300,7 @@
1221
1300
  "dependencies": [
1222
1301
  "@base-ui/react",
1223
1302
  "class-variance-authority",
1224
- "dowel-ui@^0.30.0"
1303
+ "dowel-ui@^0.31.0"
1225
1304
  ],
1226
1305
  "registryDependencies": [],
1227
1306
  "files": [
@@ -1239,7 +1318,7 @@
1239
1318
  "title": "NotificationBell",
1240
1319
  "description": "A bell that is always lit is a bell nobody reads, so the count is the product's decision and this draws it: nothing at zero, the number past that, `9+` past nine. Pressing it does not leave the screen - the last few entries open under it and the whole history is one more click, which is the shape every product converged on once the first one tried a page.",
1241
1320
  "dependencies": [
1242
- "dowel-ui@^0.30.0"
1321
+ "dowel-ui@^0.31.0"
1243
1322
  ],
1244
1323
  "registryDependencies": [
1245
1324
  "https://lacodda.github.io/dowel/r/button.json",
@@ -1261,7 +1340,7 @@
1261
1340
  "description": "A number typed into a text input is a string that happens to look like a number, and every product then writes the same four fixes: strip the letters, clamp to a range, round to a step, and decide what an empty box means. This is those four, once, plus the stepper - because a value with a small range is faster nudged than typed.",
1262
1341
  "dependencies": [
1263
1342
  "@base-ui/react",
1264
- "dowel-ui@^0.30.0"
1343
+ "dowel-ui@^0.31.0"
1265
1344
  ],
1266
1345
  "registryDependencies": [
1267
1346
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1271,7 +1350,7 @@
1271
1350
  "path": "ui/number-field.tsx",
1272
1351
  "target": "@ui/number-field.tsx",
1273
1352
  "type": "registry:ui",
1274
- "content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the reader's own,\n * which is nearly always right; a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: Intl.LocalesArgument\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n ...props\n}: NumberFieldProps) {\n return (\n <Base.Root {...props} className={cn('inline-flex items-center gap-2', className)}>\n <Base.Group\n className={cn(\n fieldClasses,\n 'flex h-control items-stretch overflow-hidden p-0',\n // The group carries the field's clothes, so the focus ring belongs\n // to the whole control rather than to the bare input inside it.\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n )}\n >\n {!hideStepper && (\n <Base.Decrement className={cn(stepperButton, 'border-r border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Decrement>\n )}\n\n <Base.Input\n placeholder={placeholder}\n aria-label={ariaLabel}\n className={cn(\n 'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n 'outline-none',\n // Figures line up in a column, which is the whole reason a number\n // is in a field of its own.\n 'tabular-nums',\n hideStepper ? 'text-left' : 'text-center',\n )}\n />\n\n {!hideStepper && (\n <Base.Increment className={cn(stepperButton, 'border-l border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M8 4v8M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Increment>\n )}\n </Base.Group>\n\n {unit !== undefined && <span className=\"shrink-0 text-xs text-dim\">{unit}</span>}\n </Base.Root>\n )\n}\n"
1353
+ "content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the application's\n * language (see `useLocale`); a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: string\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n locale,\n ...props\n}: NumberFieldProps) {\n const language = useLocale(locale)\n return (\n <Base.Root {...props} locale={language} className={cn('inline-flex items-center gap-2', className)}>\n <Base.Group\n className={cn(\n fieldClasses,\n 'flex h-control items-stretch overflow-hidden p-0',\n // The group carries the field's clothes, so the focus ring belongs\n // to the whole control rather than to the bare input inside it.\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n )}\n >\n {!hideStepper && (\n <Base.Decrement className={cn(stepperButton, 'border-r border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Decrement>\n )}\n\n <Base.Input\n placeholder={placeholder}\n aria-label={ariaLabel}\n className={cn(\n 'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n 'outline-none',\n // Figures line up in a column, which is the whole reason a number\n // is in a field of its own.\n 'tabular-nums',\n hideStepper ? 'text-left' : 'text-center',\n )}\n />\n\n {!hideStepper && (\n <Base.Increment className={cn(stepperButton, 'border-l border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M8 4v8M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Increment>\n )}\n </Base.Group>\n\n {unit !== undefined && <span className=\"shrink-0 text-xs text-dim\">{unit}</span>}\n </Base.Root>\n )\n}\n"
1275
1354
  }
1276
1355
  ]
1277
1356
  },
@@ -1281,7 +1360,7 @@
1281
1360
  "title": "NumberFormat",
1282
1361
  "description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
1283
1362
  "dependencies": [
1284
- "dowel-ui@^0.30.0"
1363
+ "dowel-ui@^0.31.0"
1285
1364
  ],
1286
1365
  "registryDependencies": [],
1287
1366
  "files": [
@@ -1289,7 +1368,7 @@
1289
1368
  "path": "ui/number-format.tsx",
1290
1369
  "target": "@ui/number-format.tsx",
1291
1370
  "type": "registry:ui",
1292
- "content": "import type { HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * A number, written the way the reader's language writes numbers.\n *\n * Two things, and the second is the reason this is a component rather than a\n * call to `toLocaleString` at each site.\n *\n * **The separators are the reader's.** A thousand is `1,000` here, `1 000`\n * there and `1.000` somewhere else, and the last one is the same string\n * another reader would read as one. `Intl` knows this and a product does not\n * have to.\n *\n * **The figures line up.** `tabular-nums` makes every digit the same width, so\n * a column of numbers has its digits above each other and the eye can compare\n * lengths without reading. Without it a proportional font gives `1` less room\n * than `8`, the column ripples, and the only way to tell 9,999 from 10,000 is\n * to count. This is the part that gets left out, because it looks fine in the\n * one number a developer tries it on and only fails in a column - which is\n * exactly where numbers live.\n *\n * `Intl.NumberFormat` covers plain numbers, currency, percentages and units\n * with the same options object, so those are not four components. What it does\n * not cover is a \"compact\" number that must not lose meaning - `1.2M` is a\n * choice about how much precision the reader is owed, and it is made by the\n * caller, in `notation`.\n *\n * No locale is defaulted. `undefined` means the reader's own, which is what a\n * product almost always wants; passing `'en-US'` to be safe is how a German\n * reader is shown American separators for the life of the product.\n */\n\n/* `style` belongs to both halves of these props and means opposite things:\n * `'currency'` to `Intl`, a CSS object to the DOM. The `Intl` one wins, since\n * it is the option a number component is actually asked for; inline styles are\n * given up in exchange, which costs nothing here - a primitive's appearance is\n * the theme's business, and `className` is still there. */\nexport interface NumberFormatProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'style'>,\n Intl.NumberFormatOptions {\n /* Required, and a number rather than `number | null`. A row with no value\n * shows whatever the product says absence looks like - a dash, a word, an\n * empty cell - and that is a decision about the data, not about formatting.\n * Accepting `null` here would put a default answer to it inside a primitive,\n * and the default would be wrong wherever absence means something. */\n value: number\n /** The reader's own by default. */\n locale?: string | string[]\n}\n\n/** Format a number without rendering it. For a `title`, an `aria-label`, a\n * CSV, or anywhere the string is needed rather than an element. */\nexport function formatNumber(\n value: number,\n locale?: string | string[],\n options?: Intl.NumberFormatOptions,\n): string {\n return new Intl.NumberFormat(locale, options).format(value)\n}\n\nexport function NumberFormat({\n value,\n locale,\n className,\n // Everything `Intl.NumberFormat` understands, pulled out of the props so\n // what remains can go on the element. Listed rather than inferred, because\n // the two sets overlap - `style` is a valid option and a valid DOM\n // attribute, and spreading `style: 'currency'` onto a `<span>` is a runtime\n // error the type system will not catch.\n style,\n currency,\n currencyDisplay,\n currencySign,\n unit,\n unitDisplay,\n notation,\n compactDisplay,\n signDisplay,\n useGrouping,\n minimumIntegerDigits,\n minimumFractionDigits,\n maximumFractionDigits,\n minimumSignificantDigits,\n maximumSignificantDigits,\n numberingSystem,\n ...props\n}: NumberFormatProps) {\n const formatted = formatNumber(value, locale, {\n style,\n currency,\n currencyDisplay,\n currencySign,\n unit,\n unitDisplay,\n notation,\n compactDisplay,\n signDisplay,\n useGrouping,\n minimumIntegerDigits,\n minimumFractionDigits,\n maximumFractionDigits,\n minimumSignificantDigits,\n maximumSignificantDigits,\n numberingSystem,\n })\n\n return (\n <span className={cn('tabular-nums', className)} {...props}>\n {formatted}\n </span>\n )\n}\n"
1371
+ "content": "import type { HTMLAttributes } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\n\n/*\n * A number, written the way the application's language writes numbers.\n *\n * Two things, and the second is the reason this is a component rather than a\n * call to `toLocaleString` at each site.\n *\n * **The separators are the language's.** A thousand is `1,000` here, `1 000`\n * there and `1.000` somewhere else, and the last one is the same string\n * another reader would read as one. `Intl` knows this and a product does not\n * have to.\n *\n * **The figures line up.** `tabular-nums` makes every digit the same width, so\n * a column of numbers has its digits above each other and the eye can compare\n * lengths without reading. Without it a proportional font gives `1` less room\n * than `8`, the column ripples, and the only way to tell 9,999 from 10,000 is\n * to count. This is the part that gets left out, because it looks fine in the\n * one number a developer tries it on and only fails in a column - which is\n * exactly where numbers live.\n *\n * `Intl.NumberFormat` covers plain numbers, currency, percentages and units\n * with the same options object, so those are not four components. What it does\n * not cover is a \"compact\" number that must not lose meaning - `1.2M` is a\n * choice about how much precision the reader is owed, and it is made by the\n * caller, in `notation`.\n *\n * **The language is the application's, not the browser's.** Left unset, the\n * locale is `useLocale()`'s answer: a `LocaleProvider` above, else the page's\n * `<html lang>`. It used to be the browser's language, and that is how\n * kasl-server showed an English interface with Russian week headings to a\n * reader whose browser happened to speak Russian. `formatNumber` has no\n * provider to ask, so it takes the locale as a required argument - a call\n * that forgets it does not compile.\n */\n\n/* `style` belongs to both halves of these props and means opposite things:\n * `'currency'` to `Intl`, a CSS object to the DOM. The `Intl` one wins, since\n * it is the option a number component is actually asked for; inline styles are\n * given up in exchange, which costs nothing here - a primitive's appearance is\n * the theme's business, and `className` is still there. */\nexport interface NumberFormatProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'style'>,\n Intl.NumberFormatOptions {\n /* Required, and a number rather than `number | null`. A row with no value\n * shows whatever the product says absence looks like - a dash, a word, an\n * empty cell - and that is a decision about the data, not about formatting.\n * Accepting `null` here would put a default answer to it inside a primitive,\n * and the default would be wrong wherever absence means something. */\n value: number\n /** The application's language by default - see `useLocale`. */\n locale?: string\n}\n\n/** Format a number without rendering it. For a `title`, an `aria-label`, a\n * CSV, or anywhere the string is needed rather than an element. */\nexport function formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string {\n return new Intl.NumberFormat(locale, options).format(value)\n}\n\nexport function NumberFormat({\n value,\n locale,\n className,\n // Everything `Intl.NumberFormat` understands, pulled out of the props so\n // what remains can go on the element. Listed rather than inferred, because\n // the two sets overlap - `style` is a valid option and a valid DOM\n // attribute, and spreading `style: 'currency'` onto a `<span>` is a runtime\n // error the type system will not catch.\n style,\n currency,\n currencyDisplay,\n currencySign,\n unit,\n unitDisplay,\n notation,\n compactDisplay,\n signDisplay,\n useGrouping,\n minimumIntegerDigits,\n minimumFractionDigits,\n maximumFractionDigits,\n minimumSignificantDigits,\n maximumSignificantDigits,\n numberingSystem,\n ...props\n}: NumberFormatProps) {\n const language = useLocale(locale)\n const formatted = formatNumber(value, language, {\n style,\n currency,\n currencyDisplay,\n currencySign,\n unit,\n unitDisplay,\n notation,\n compactDisplay,\n signDisplay,\n useGrouping,\n minimumIntegerDigits,\n minimumFractionDigits,\n maximumFractionDigits,\n minimumSignificantDigits,\n maximumSignificantDigits,\n numberingSystem,\n })\n\n return (\n <span className={cn('tabular-nums', className)} {...props}>\n {formatted}\n </span>\n )\n}\n"
1293
1372
  }
1294
1373
  ]
1295
1374
  },
@@ -1300,7 +1379,7 @@
1300
1379
  "description": "What a screen says it is, and the buttons that act on the whole of it: a title, a line under it, and the actions at the far end of the same row.",
1301
1380
  "dependencies": [
1302
1381
  "class-variance-authority",
1303
- "dowel-ui@^0.30.0"
1382
+ "dowel-ui@^0.31.0"
1304
1383
  ],
1305
1384
  "registryDependencies": [],
1306
1385
  "files": [
@@ -1318,7 +1397,7 @@
1318
1397
  "title": "PageSize",
1319
1398
  "description": "Its own file rather than a part of `Pagination`, because the two are needed apart often enough: a list that scrolls for ever wants \"how many to load at a time\" and no page buttons, and a table with a fixed page size wants the buttons and no choice. Together they were also over the size gate, which asked the right question.",
1320
1399
  "dependencies": [
1321
- "dowel-ui@^0.30.0"
1400
+ "dowel-ui@^0.31.0"
1322
1401
  ],
1323
1402
  "registryDependencies": [
1324
1403
  "https://lacodda.github.io/dowel/r/select.json"
@@ -1338,7 +1417,7 @@
1338
1417
  "title": "Pagination",
1339
1418
  "description": "The arithmetic is exported separately from the component for the same reason `table-sort` is a file of its own: a product that pages on the server needs the page numbers and not the buttons, and computing them a second time in a different place is how the two disagree about where the last page ends.",
1340
1419
  "dependencies": [
1341
- "dowel-ui@^0.30.0"
1420
+ "dowel-ui@^0.31.0"
1342
1421
  ],
1343
1422
  "registryDependencies": [
1344
1423
  "https://lacodda.github.io/dowel/r/button.json"
@@ -1359,7 +1438,7 @@
1359
1438
  "description": "The raised surface everything else sits on. It is the one place a screen gets its structure from, so it stays deliberately plain: a ground, a hairline, a corner.",
1360
1439
  "dependencies": [
1361
1440
  "class-variance-authority",
1362
- "dowel-ui@^0.30.0"
1441
+ "dowel-ui@^0.31.0"
1363
1442
  ],
1364
1443
  "registryDependencies": [],
1365
1444
  "files": [
@@ -1377,7 +1456,7 @@
1377
1456
  "title": "PasswordField",
1378
1457
  "description": "The reveal is the whole component, and it is not a convenience. A masked field is the only one in a form where a typo cannot be seen, so people either paste (fine) or type slowly and get it wrong anyway; the toggle is what turns an unverifiable field into a checkable one, and it is why long passphrases became usable at all.",
1379
1458
  "dependencies": [
1380
- "dowel-ui@^0.30.0"
1459
+ "dowel-ui@^0.31.0"
1381
1460
  ],
1382
1461
  "registryDependencies": [
1383
1462
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1399,7 +1478,7 @@
1399
1478
  "dependencies": [
1400
1479
  "@base-ui/react",
1401
1480
  "class-variance-authority",
1402
- "dowel-ui@^0.30.0"
1481
+ "dowel-ui@^0.31.0"
1403
1482
  ],
1404
1483
  "registryDependencies": [],
1405
1484
  "files": [
@@ -1419,7 +1498,7 @@
1419
1498
  "dependencies": [
1420
1499
  "@base-ui/react",
1421
1500
  "class-variance-authority",
1422
- "dowel-ui@^0.30.0"
1501
+ "dowel-ui@^0.31.0"
1423
1502
  ],
1424
1503
  "registryDependencies": [],
1425
1504
  "files": [
@@ -1431,6 +1510,45 @@
1431
1510
  }
1432
1511
  ]
1433
1512
  },
1513
+ {
1514
+ "name": "product-mark",
1515
+ "type": "registry:ui",
1516
+ "title": "ProductMark",
1517
+ "description": "The product-mark primitive.",
1518
+ "dependencies": [
1519
+ "dowel-ui@^0.31.0"
1520
+ ],
1521
+ "registryDependencies": [],
1522
+ "files": [
1523
+ {
1524
+ "path": "ui/product-mark.tsx",
1525
+ "target": "@ui/product-mark.tsx",
1526
+ "type": "registry:ui",
1527
+ "content": "import { useId } from 'react'\nimport { cn } from 'dowel-ui'\nimport { markLevel, marks, type MarkName } from 'dowel-ui/marks'\n\n/*\n * ProductMark - a product's mark from the lacodda line, drawn at the level its\n * size calls for.\n *\n * Every product of the line has one mark, a hexagonal tile with a two-letter\n * code, in three levels that are a rule rather than options: from 64px it\n * carries the product's metaphor under the code, from 28px it is the code\n * alone and larger, and at 27px and under it is the tile filled with the\n * product's colour and a heavy code - a metaphor at sixteen pixels is noise,\n * and a thin outline disappears. A product that drew its own mark picked one\n * level and used it at every size, so the same mark was a smudge in the tab\n * strip and a bare tile on the About screen. Here the size is the only input,\n * and the level follows.\n *\n * The masters are the line's, drawn by its mark generator and shipped in the\n * package (`dowel-ui/marks`); nothing is redrawn here. The tile is dark in\n * both themes, as the line draws it everywhere - the mark is the product's\n * signature, not part of the screen's palette.\n *\n * A mark beside the product's name is decoration and is hidden from a reader,\n * who already has the name. Given `label`, it becomes an image with that name,\n * for the places where the mark stands alone.\n */\n\nexport interface ProductMarkProps {\n /** Which mark: a product of the line, or `lacodda` for the line itself. */\n product: MarkName\n /** Its width and height in CSS pixels, which also choose its level. */\n size?: number\n /** Its name, when nothing next to it says which product it is. */\n label?: string\n className?: string\n}\n\nexport function ProductMark({ product, size = 24, label, className }: ProductMarkProps) {\n // SVG ids are global to the document, and a two-colour mark names its\n // gradient: two such marks on one screen would share the first one's. The\n // id React makes is unique, but carries characters an `url(#...)` reference\n // does not tolerate everywhere.\n const id = `mark-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`\n const markup = marks[product][markLevel(size)].replaceAll('{{id}}', id)\n\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 100 100\"\n role={label ? 'img' : undefined}\n aria-label={label}\n aria-hidden={label ? undefined : true}\n data-mark={product}\n data-level={markLevel(size)}\n className={cn('shrink-0', className)}\n dangerouslySetInnerHTML={{ __html: markup }}\n />\n )\n}\n\n/** The line's own mark - λ on a graphite tile - for the places that say a\n * product belongs to the lacodda line. */\nexport function LineMark(props: Omit<ProductMarkProps, 'product'>) {\n return <ProductMark product=\"lacodda\" {...props} />\n}\n"
1528
+ }
1529
+ ]
1530
+ },
1531
+ {
1532
+ "name": "product-switcher",
1533
+ "type": "registry:ui",
1534
+ "title": "ProductSwitcher",
1535
+ "description": "The line's products are meant to be used together: kasl-server reads what kasl records, rigger plans what furca commits, a note in scheda cites a track in lyrid. A person moving between them was typing addresses from memory. This is the one place that knows where the others are - the current product's small tile and name on the trigger, and the others in a menu, each with its own tile, so the colour a person already knows the product by is what they look for.",
1536
+ "dependencies": [
1537
+ "dowel-ui@^0.31.0"
1538
+ ],
1539
+ "registryDependencies": [
1540
+ "https://lacodda.github.io/dowel/r/menu.json",
1541
+ "https://lacodda.github.io/dowel/r/product-mark.json"
1542
+ ],
1543
+ "files": [
1544
+ {
1545
+ "path": "ui/product-switcher.tsx",
1546
+ "target": "@ui/product-switcher.tsx",
1547
+ "type": "registry:ui",
1548
+ "content": "import { cn } from 'dowel-ui'\nimport type { MarkName } from 'dowel-ui/marks'\nimport { Menu, MenuItem, MenuPopup, MenuTrigger } from './menu'\nimport { ProductMark } from './product-mark'\n\n/*\n * ProductSwitcher - the way from one product of the line to the next.\n *\n * The line's products are meant to be used together: kasl-server reads what\n * kasl records, rigger plans what furca commits, a note in scheda cites a\n * track in lyrid. A person moving between them was typing addresses from\n * memory. This is the one place that knows where the others are - the current\n * product's small tile and name on the trigger, and the others in a menu, each\n * with its own tile, so the colour a person already knows the product by is\n * what they look for.\n *\n * The component knows nothing about where a product lives: the list, its\n * order and every address come from the caller, because which products a\n * person has, and on which host, is the deployment's to say. An entry with\n * `href` is a link - it opens with a middle click in a new tab, which a person\n * moving between web products expects - and one with `onSelect` is an action,\n * for a desktop product that opens another by launching it.\n *\n * The current product is not in the menu. It is on the trigger; listing it\n * again offers a choice that changes nothing.\n */\n\nexport interface SwitcherProduct {\n product: MarkName\n /** The name, as the product writes it. */\n name: string\n /** A few words on what it is, under the name. */\n description?: string\n /** Where it lives. */\n href?: string\n /** What choosing it does, when it is not a place to go. */\n onSelect?: () => void\n}\n\nexport interface ProductSwitcherProps {\n /** The product this is. */\n current: MarkName\n /** Its name, shown on the trigger. */\n currentName: string\n /** The others, in the order to offer them. */\n products: SwitcherProduct[]\n /** What the trigger does, for a reader: \"Switch product\". */\n label: string\n className?: string\n}\n\nexport function ProductSwitcher({ current, currentName, products, label, className }: ProductSwitcherProps) {\n return (\n <Menu>\n <MenuTrigger\n className={cn(\n 'inline-flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-sm font-semibold text-text',\n 'transition-colors duration-quick hover:bg-soft data-[popup-open]:bg-soft',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n >\n <ProductMark product={current} size={18} />\n <span>{currentName}</span>\n {/* The name a reader hears is the visible one and then what the\n button does; a space of its own keeps the two words apart. */}\n {' '}\n <span className=\"sr-only\">{label}</span>\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" aria-hidden className=\"text-faint\">\n <path d=\"M2 3.5l3 3 3-3\" />\n </svg>\n </MenuTrigger>\n <MenuPopup align=\"start\" className=\"min-w-60\">\n {products\n .filter((entry) => entry.product !== current)\n .map((entry) => (\n <MenuItem\n key={entry.product}\n onClick={entry.onSelect}\n render={entry.href ? <a href={entry.href} /> : undefined}\n className=\"gap-2.5 py-1.5\"\n >\n <ProductMark product={entry.product} size={20} />\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-text\">{entry.name}</span>\n {entry.description && <span className=\"truncate text-xs text-dim\">{entry.description}</span>}\n </span>\n </MenuItem>\n ))}\n </MenuPopup>\n </Menu>\n )\n}\n"
1549
+ }
1550
+ ]
1551
+ },
1434
1552
  {
1435
1553
  "name": "progress",
1436
1554
  "type": "registry:ui",
@@ -1439,7 +1557,7 @@
1439
1557
  "dependencies": [
1440
1558
  "@base-ui/react",
1441
1559
  "class-variance-authority",
1442
- "dowel-ui@^0.30.0"
1560
+ "dowel-ui@^0.31.0"
1443
1561
  ],
1444
1562
  "registryDependencies": [],
1445
1563
  "files": [
@@ -1478,7 +1596,7 @@
1478
1596
  "dependencies": [
1479
1597
  "@base-ui/react",
1480
1598
  "class-variance-authority",
1481
- "dowel-ui@^0.30.0"
1599
+ "dowel-ui@^0.31.0"
1482
1600
  ],
1483
1601
  "registryDependencies": [],
1484
1602
  "files": [
@@ -1496,7 +1614,7 @@
1496
1614
  "title": "RatingScale",
1497
1615
  "description": "Generalised from kilna, where it is how a work is scored on each of its axes. The shape is a row of marks rather than stars: stars carry a meaning of their own - a review, a public verdict - and this is as often \"how hard was this\" or \"how finished is it\" as it is \"how good\".",
1498
1616
  "dependencies": [
1499
- "dowel-ui@^0.30.0"
1617
+ "dowel-ui@^0.31.0"
1500
1618
  ],
1501
1619
  "registryDependencies": [],
1502
1620
  "files": [
@@ -1514,7 +1632,7 @@
1514
1632
  "title": "RelativeTime",
1515
1633
  "description": "The relative-time primitive.",
1516
1634
  "dependencies": [
1517
- "dowel-ui@^0.30.0"
1635
+ "dowel-ui@^0.31.0"
1518
1636
  ],
1519
1637
  "registryDependencies": [],
1520
1638
  "files": [
@@ -1522,7 +1640,7 @@
1522
1640
  "path": "ui/relative-time.tsx",
1523
1641
  "target": "@ui/relative-time.tsx",
1524
1642
  "type": "registry:ui",
1525
- "content": "import { useState, type TimeHTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * \"3 minutes ago\", in the reader's language, over a date they can still read.\n *\n * The rule the component is built on: **relative time is a convenience, never\n * the only copy of the fact.** \"Last week\" is quicker to read than a date and\n * useless the moment the reader needs to say when something actually happened\n * - and they cannot get it back, because the page has thrown it away. So the\n * element is a `<time dateTime=…>` with the exact moment in its `title`: the\n * machine-readable value stays, hovering shows the real date, and copying the\n * text still yields something a person can act on.\n *\n * `Intl.RelativeTimeFormat` writes the phrase, which is the whole reason there\n * is no date library here. \"yesterday\", \"3 недели назад\", \"in 2 months\" - the\n * plural rules and the special words for the nearest units are the part a\n * hand-written version gets wrong first, and it gets it wrong only in the\n * languages its author does not read.\n *\n * What `Intl` does not decide is which unit to use: it is told \"3\" and\n * \"weeks\". Choosing between them is `relativeParts` below, and it is the one\n * piece of arithmetic here.\n *\n * Deliberately not self-updating. A component that re-renders every second to\n * keep \"2 minutes ago\" honest costs a timer per instance - a table of fifty\n * rows is fifty timers - to correct a number nobody is watching change. A\n * product that needs it re-renders the list on its own schedule; the `now`\n * prop is there so it can, and so tests are not written against the clock.\n */\n\n/** The thresholds, largest unit first: how many seconds it takes to earn one,\n * and the unit `Intl` should be handed.\n *\n * A month is 30 days and a year is 365. Both are approximations, and they are\n * the right ones: this is a phrase saying roughly how long ago, and a reader\n * who needs the exact interval is reading the date in the `title` instead. */\nconst UNITS: [seconds: number, unit: Intl.RelativeTimeFormatUnit][] = [\n [60 * 60 * 24 * 365, 'year'],\n [60 * 60 * 24 * 30, 'month'],\n [60 * 60 * 24 * 7, 'week'],\n [60 * 60 * 24, 'day'],\n [60 * 60, 'hour'],\n [60, 'minute'],\n [1, 'second'],\n]\n\n/** How long ago, as a count and a unit: what `Intl.RelativeTimeFormat` needs.\n *\n * The sign is the one `Intl` wants - negative for the past - and the count is\n * rounded towards zero. Rounding to nearest is the tempting alternative and\n * says \"in 1 hour\" 31 minutes before the meeting; truncating never claims more\n * time has passed than has. */\nexport function relativeParts(\n from: Date | number | string,\n now: Date | number = Date.now(),\n): [value: number, unit: Intl.RelativeTimeFormatUnit] {\n const then = new Date(from).getTime()\n const seconds = (then - new Date(now).getTime()) / 1000\n const magnitude = Math.abs(seconds)\n\n for (const [size, unit] of UNITS) {\n if (magnitude >= size) return [Math.trunc(seconds / size), unit]\n }\n // Under a second either way. Zero seconds rather than the smallest unit,\n // because `Intl` turns that into \"now\" in every language it knows.\n return [0, 'second']\n}\n\n/** The phrase alone, for a `title`, an `aria-label` or a string. */\nexport function formatRelative(\n from: Date | number | string,\n now: Date | number = Date.now(),\n locale?: string | string[],\n options?: Intl.RelativeTimeFormatOptions,\n): string {\n const [value, unit] = relativeParts(from, now)\n return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', ...options }).format(value, unit)\n}\n\nexport interface RelativeTimeProps extends Omit<TimeHTMLAttributes<HTMLTimeElement>, 'title'> {\n /** The moment being described. A `Date`, epoch milliseconds, or an ISO\n * string - whichever the data already holds. */\n value: Date | number | string\n /** What counts as now. Given rather than read from the clock so a list can\n * re-render on its own schedule, and so a test is not written against the\n * time it runs at. */\n now?: Date | number\n locale?: string | string[]\n /** `'auto'` by default, which is what produces \"yesterday\" rather than \"1\n * day ago\" where the language has a word for it. Pass `'always'` for a\n * column where every row should read the same way. */\n numeric?: Intl.RelativeTimeFormatOptions['numeric']\n /** How the exact moment is written in the `title`. The reader's own format\n * by default. */\n titleOptions?: Intl.DateTimeFormatOptions\n}\n\nexport function RelativeTime({\n value,\n now,\n locale,\n numeric = 'auto',\n titleOptions = { dateStyle: 'medium', timeStyle: 'short' },\n className,\n ...props\n}: RelativeTimeProps) {\n /* Read once, at mount, rather than in a default argument.\n *\n * `now = Date.now()` in the parameter list is the obvious spelling and is\n * impure: it is evaluated on every render, so \"now\" moves whenever the\n * parent happens to re-render and the phrase changes for reasons that have\n * nothing to do with this component. Caught by `react-hooks/purity`, and\n * worth keeping caught - a component that deliberately does not update\n * itself must not update itself by accident either.\n *\n * With `now` given, the state is initialised and never read, which is what a\n * product paging a list wants: every row is measured from the same moment. */\n const [mountedAt] = useState(() => Date.now())\n const moment = new Date(value)\n const invalid = Number.isNaN(moment.getTime())\n\n if (invalid) {\n // A date that is not a date renders as nothing rather than as \"Invalid\n // Date\", which is a string no reader can do anything with and which looks\n // like a value. The `<time>` element with no `dateTime` says the same to a\n // machine: there is no moment here.\n return <time className={className} {...props} />\n }\n\n return (\n <time\n dateTime={moment.toISOString()}\n // The fact itself, kept. The phrase above it is the convenience.\n title={new Intl.DateTimeFormat(locale, titleOptions).format(moment)}\n className={cn('whitespace-nowrap', className)}\n {...props}\n >\n {formatRelative(moment, now ?? mountedAt, locale, { numeric })}\n </time>\n )\n}\n"
1643
+ "content": "import { useState, type TimeHTMLAttributes } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\n\n/*\n * \"3 minutes ago\", in the application's language, over a date they can still read.\n *\n * The rule the component is built on: **relative time is a convenience, never\n * the only copy of the fact.** \"Last week\" is quicker to read than a date and\n * useless the moment the reader needs to say when something actually happened\n * - and they cannot get it back, because the page has thrown it away. So the\n * element is a `<time dateTime=…>` with the exact moment in its `title`: the\n * machine-readable value stays, hovering shows the real date, and copying the\n * text still yields something a person can act on.\n *\n * `Intl.RelativeTimeFormat` writes the phrase, which is the whole reason there\n * is no date library here. \"yesterday\", \"3 недели назад\", \"in 2 months\" - the\n * plural rules and the special words for the nearest units are the part a\n * hand-written version gets wrong first, and it gets it wrong only in the\n * languages its author does not read.\n *\n * What `Intl` does not decide is which unit to use: it is told \"3\" and\n * \"weeks\". Choosing between them is `relativeParts` below, and it is the one\n * piece of arithmetic here.\n *\n * Deliberately not self-updating. A component that re-renders every second to\n * keep \"2 minutes ago\" honest costs a timer per instance - a table of fifty\n * rows is fifty timers - to correct a number nobody is watching change. A\n * product that needs it re-renders the list on its own schedule; the `now`\n * prop is there so it can, and so tests are not written against the clock.\n */\n\n/** The thresholds, largest unit first: how many seconds it takes to earn one,\n * and the unit `Intl` should be handed.\n *\n * A month is 30 days and a year is 365. Both are approximations, and they are\n * the right ones: this is a phrase saying roughly how long ago, and a reader\n * who needs the exact interval is reading the date in the `title` instead. */\nconst UNITS: [seconds: number, unit: Intl.RelativeTimeFormatUnit][] = [\n [60 * 60 * 24 * 365, 'year'],\n [60 * 60 * 24 * 30, 'month'],\n [60 * 60 * 24 * 7, 'week'],\n [60 * 60 * 24, 'day'],\n [60 * 60, 'hour'],\n [60, 'minute'],\n [1, 'second'],\n]\n\n/** How long ago, as a count and a unit: what `Intl.RelativeTimeFormat` needs.\n *\n * The sign is the one `Intl` wants - negative for the past - and the count is\n * rounded towards zero. Rounding to nearest is the tempting alternative and\n * says \"in 1 hour\" 31 minutes before the meeting; truncating never claims more\n * time has passed than has. */\nexport function relativeParts(\n from: Date | number | string,\n now: Date | number = Date.now(),\n): [value: number, unit: Intl.RelativeTimeFormatUnit] {\n const then = new Date(from).getTime()\n const seconds = (then - new Date(now).getTime()) / 1000\n const magnitude = Math.abs(seconds)\n\n for (const [size, unit] of UNITS) {\n if (magnitude >= size) return [Math.trunc(seconds / size), unit]\n }\n // Under a second either way. Zero seconds rather than the smallest unit,\n // because `Intl` turns that into \"now\" in every language it knows.\n return [0, 'second']\n}\n\n/** The phrase alone, for a `title`, an `aria-label` or a string. */\nexport function formatRelative(\n from: Date | number | string,\n locale: string,\n now: Date | number = Date.now(),\n options?: Intl.RelativeTimeFormatOptions,\n): string {\n const [value, unit] = relativeParts(from, now)\n return new Intl.RelativeTimeFormat(locale, { numeric: 'auto', ...options }).format(value, unit)\n}\n\nexport interface RelativeTimeProps extends Omit<TimeHTMLAttributes<HTMLTimeElement>, 'title'> {\n /** The moment being described. A `Date`, epoch milliseconds, or an ISO\n * string - whichever the data already holds. */\n value: Date | number | string\n /** What counts as now. Given rather than read from the clock so a list can\n * re-render on its own schedule, and so a test is not written against the\n * time it runs at. */\n now?: Date | number\n /** The application's language by default - see `useLocale`. */\n locale?: string\n /** `'auto'` by default, which is what produces \"yesterday\" rather than \"1\n * day ago\" where the language has a word for it. Pass `'always'` for a\n * column where every row should read the same way. */\n numeric?: Intl.RelativeTimeFormatOptions['numeric']\n /** How the exact moment is written in the `title`. The application's own format\n * by default. */\n titleOptions?: Intl.DateTimeFormatOptions\n}\n\nexport function RelativeTime({\n value,\n now,\n locale,\n numeric = 'auto',\n titleOptions = { dateStyle: 'medium', timeStyle: 'short' },\n className,\n ...props\n}: RelativeTimeProps) {\n /* Read once, at mount, rather than in a default argument.\n *\n * `now = Date.now()` in the parameter list is the obvious spelling and is\n * impure: it is evaluated on every render, so \"now\" moves whenever the\n * parent happens to re-render and the phrase changes for reasons that have\n * nothing to do with this component. Caught by `react-hooks/purity`, and\n * worth keeping caught - a component that deliberately does not update\n * itself must not update itself by accident either.\n *\n * With `now` given, the state is initialised and never read, which is what a\n * product paging a list wants: every row is measured from the same moment. */\n const [mountedAt] = useState(() => Date.now())\n const language = useLocale(locale)\n const moment = new Date(value)\n const invalid = Number.isNaN(moment.getTime())\n\n if (invalid) {\n // A date that is not a date renders as nothing rather than as \"Invalid\n // Date\", which is a string no reader can do anything with and which looks\n // like a value. The `<time>` element with no `dateTime` says the same to a\n // machine: there is no moment here.\n return <time className={className} {...props} />\n }\n\n return (\n <time\n dateTime={moment.toISOString()}\n // The fact itself, kept. The phrase above it is the convenience.\n title={new Intl.DateTimeFormat(language, titleOptions).format(moment)}\n className={cn('whitespace-nowrap', className)}\n {...props}\n >\n {formatRelative(moment, language, now ?? mountedAt, { numeric })}\n </time>\n )\n}\n"
1526
1644
  }
1527
1645
  ]
1528
1646
  },
@@ -1532,7 +1650,7 @@
1532
1650
  "title": "ReorderableList",
1533
1651
  "description": "The columns in a column picker, the stops of a dial, the roles of a profile. A hook and a grip rather than a list component: the rows are already something else's - a menu's items, a form's fields - and a component wrapping them would have to reproduce whatever that something else does.",
1534
1652
  "dependencies": [
1535
- "dowel-ui@^0.30.0"
1653
+ "dowel-ui@^0.31.0"
1536
1654
  ],
1537
1655
  "registryDependencies": [],
1538
1656
  "files": [
@@ -1550,7 +1668,7 @@
1550
1668
  "title": "SaveState",
1551
1669
  "description": "The quiet line beside a field that saves itself: \"saving…\", then a tick that fades. It exists because a form without a Save button has to say what it did anyway - otherwise the reader is left guessing whether their edit survived, and the usual answer to that guess is to press Ctrl+S at a page that has no such thing.",
1552
1670
  "dependencies": [
1553
- "dowel-ui@^0.30.0"
1671
+ "dowel-ui@^0.31.0"
1554
1672
  ],
1555
1673
  "registryDependencies": [
1556
1674
  "https://lacodda.github.io/dowel/r/spinner.json"
@@ -1564,13 +1682,33 @@
1564
1682
  }
1565
1683
  ]
1566
1684
  },
1685
+ {
1686
+ "name": "scroll-area",
1687
+ "type": "registry:ui",
1688
+ "title": "ScrollArea",
1689
+ "description": "The line has one rule for scrollbars: a thin bar laid over the content, never a gutter that takes width. A gutter is a column of the layout that comes and goes with the length of the content, so a list that grows past the fold pushes everything beside it sideways by the width of a bar - and on a platform that draws a classic bar, the product wears someone else's chrome in the middle of its own screen. The theme's global scrollbar rules make native bars thin, but thin is still a gutter wherever the platform draws one; only a bar that is not the browser's can promise to take no room.",
1690
+ "dependencies": [
1691
+ "@base-ui/react",
1692
+ "class-variance-authority",
1693
+ "dowel-ui@^0.31.0"
1694
+ ],
1695
+ "registryDependencies": [],
1696
+ "files": [
1697
+ {
1698
+ "path": "ui/scroll-area.tsx",
1699
+ "target": "@ui/scroll-area.tsx",
1700
+ "type": "registry:ui",
1701
+ "content": "import type { ReactNode, Ref } from 'react'\nimport { ScrollArea as Base } from '@base-ui/react/scroll-area'\nimport { cva } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A box that scrolls, with the line's scrollbar drawn over its content.\n *\n * The line has one rule for scrollbars: a thin bar laid over the content,\n * never a gutter that takes width. A gutter is a column of the layout that\n * comes and goes with the length of the content, so a list that grows past\n * the fold pushes everything beside it sideways by the width of a bar - and\n * on a platform that draws a classic bar, the product wears someone else's\n * chrome in the middle of its own screen. The theme's global scrollbar rules\n * make native bars thin, but thin is still a gutter wherever the platform\n * draws one; only a bar that is not the browser's can promise to take no room.\n *\n * So the viewport hides its native bars and Base UI draws the thumb, sized and\n * placed from the scroll position. The bar is invisible at rest and appears\n * while the pointer is over the area or while it scrolls, because a bar that\n * is always there is a line across the content that nobody asked for. The\n * track is wider than the thumb and the thumb thickens under the pointer: a\n * four-pixel thumb reads as a hint and is hopeless as a grip, and the grip is\n * what somebody reaching for it wants. Both axes are handled, and Base UI\n * mounts a bar only for an axis that actually overflows.\n *\n * The keyboard is the part a hand-drawn scrollbar usually loses. A box that\n * scrolls must be reachable, or a reader without a pointer cannot see what is\n * below the fold (axe's `scrollable-region-focusable`), so Base UI makes the\n * viewport a tab stop exactly when it overflows. A tab stop has to be called\n * something, and the viewport is then a named group - which is why `label` is\n * required: whether the content will overflow is not known when the product\n * is written, and a name added only for the long case is a name forgotten for\n * it.\n *\n * `fade` softens the edges where there is more to see. It reads Base UI's\n * overflow distances, so an edge that is already at its end is left sharp -\n * a fade on both ends of a list scrolled to the top would say there is\n * something above when there is not.\n */\n\nexport const scrollAreaViewportVariants = cva(\n cn(\n 'size-full rounded-[inherit] outline-none',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n ),\n {\n variants: {\n /* A mask, not an overlay gradient: a gradient would have to be painted\n * in the colour of whatever is behind the area, which the component\n * cannot know. A mask fades the content itself, over any ground. Each\n * edge fades by at most `--fade`, and by less while the content is\n * nearer its end than that - so the fade arrives as scrolling starts\n * rather than switching on. */\n fade: {\n true: cn(\n '[--fade:calc(var(--spacing)*6)] [mask-composite:intersect]',\n '[mask-image:linear-gradient(to_bottom,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-y-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-y-end,0px))),transparent),linear-gradient(to_right,transparent,var(--color-black)_min(var(--fade),var(--scroll-area-overflow-x-start,0px)),var(--color-black)_calc(100%_-_min(var(--fade),var(--scroll-area-overflow-x-end,0px))),transparent)]',\n ),\n false: '',\n },\n },\n defaultVariants: { fade: false },\n },\n)\n\n/* The track is the grab zone and the thumb sits at its outer edge. Hidden at\n * rest; shown while the pointer is over the area or it scrolls, and held\n * while the thumb is being dragged, since the pointer may leave the area\n * mid-drag. */\nconst scrollbar = cn(\n 'group/bar flex p-0.5 opacity-0 [transition:opacity_var(--duration-slow)_var(--ease-out)]',\n 'data-[hovering]:opacity-100 data-[scrolling]:opacity-100 active:opacity-100',\n 'data-[orientation=vertical]:w-2.5 data-[orientation=vertical]:justify-end',\n 'data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:justify-end',\n)\n\nconst thumb = cn(\n 'rounded-full bg-line-2 group-hover/bar:bg-dim',\n '[transition:width_var(--duration-quick)_var(--ease-out),height_var(--duration-quick)_var(--ease-out),background-color_var(--duration-quick)_var(--ease-out)]',\n 'data-[orientation=vertical]:w-1 group-hover/bar:data-[orientation=vertical]:w-full',\n 'data-[orientation=horizontal]:h-1 group-hover/bar:data-[orientation=horizontal]:h-full',\n)\n\nexport interface ScrollAreaProps {\n /** Names the viewport, which becomes a tab stop when its content overflows:\n * \"Recent activity\", \"Diff of main.rs\". No default - it is the product's\n * word. */\n label: string\n /** Fade the edges that have more content past them. */\n fade?: boolean\n /** The scrolling element itself, for a product that sets or reads its\n * position - a virtual list, a scroll-to-latest. */\n viewportRef?: Ref<HTMLDivElement>\n /** On the root: its size is the area's size, so the height goes here. */\n className?: string\n viewportClassName?: string\n children: ReactNode\n}\n\nexport function ScrollArea({\n label,\n fade = false,\n viewportRef,\n className,\n viewportClassName,\n children,\n}: ScrollAreaProps) {\n return (\n <Base.Root className={cn('relative min-h-0 min-w-0 overflow-hidden', className)}>\n <Base.Viewport\n ref={viewportRef}\n // Base UI gives the viewport `presentation`, which is right for a box\n // nobody can focus and wrong for one that is a tab stop: the role is\n // dropped the moment it is focusable, and the name goes with it.\n role=\"group\"\n aria-label={label}\n className={cn(scrollAreaViewportVariants({ fade }), viewportClassName)}\n >\n {/* The content box is what the horizontal overflow is measured on:\n * without it a wide child is squeezed to the viewport's width and\n * nothing ever overflows sideways. */}\n <Base.Content>{children}</Base.Content>\n </Base.Viewport>\n <Base.Scrollbar orientation=\"vertical\" className={scrollbar}>\n <Base.Thumb className={thumb} />\n </Base.Scrollbar>\n <Base.Scrollbar orientation=\"horizontal\" className={scrollbar}>\n <Base.Thumb className={thumb} />\n </Base.Scrollbar>\n </Base.Root>\n )\n}\n"
1702
+ }
1703
+ ]
1704
+ },
1567
1705
  {
1568
1706
  "name": "search-field",
1569
1707
  "type": "registry:ui",
1570
1708
  "title": "SearchField",
1571
1709
  "description": "An Input that knows it is a search box, which is three small things the products kept not doing:\n * - a magnifier, so the field is recognisable before it is read; - a way to clear it that is not \"select all and delete\" - and one that a keyboard can reach, which a decorative `<span>` cannot; - the shortcut that focuses it, shown in the field rather than learned.",
1572
1710
  "dependencies": [
1573
- "dowel-ui@^0.30.0"
1711
+ "dowel-ui@^0.31.0"
1574
1712
  ],
1575
1713
  "registryDependencies": [
1576
1714
  "https://lacodda.github.io/dowel/r/input.json",
@@ -1593,7 +1731,7 @@
1593
1731
  "description": "A settings screen is the usual case: five or six sections, each its own address so it can be linked to and the back button walks between them, listed down the left with the current one tinted. Every product draws the same column, and every product draws the active row a little differently - which is exactly the drift a shared list exists to stop.",
1594
1732
  "dependencies": [
1595
1733
  "@base-ui/react",
1596
- "dowel-ui@^0.30.0"
1734
+ "dowel-ui@^0.31.0"
1597
1735
  ],
1598
1736
  "registryDependencies": [],
1599
1737
  "files": [
@@ -1613,7 +1751,7 @@
1613
1751
  "dependencies": [
1614
1752
  "@base-ui/react",
1615
1753
  "class-variance-authority",
1616
- "dowel-ui@^0.30.0"
1754
+ "dowel-ui@^0.31.0"
1617
1755
  ],
1618
1756
  "registryDependencies": [
1619
1757
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1649,7 +1787,7 @@
1649
1787
  "title": "SkeletonOf",
1650
1788
  "description": "`Skeleton` and its shapes solved half the problem: they gave a product a list, a card and a grid to reach for instead of a spinner. The half left over is the one that actually causes the jump, and it is a human one - somebody has to look at the real thing, judge how many rows it has and how tall they are, and type that in. The judgement is made once, the screen changes a month later, and the placeholder goes on promising the old shape.",
1651
1789
  "dependencies": [
1652
- "dowel-ui@^0.30.0"
1790
+ "dowel-ui@^0.31.0"
1653
1791
  ],
1654
1792
  "registryDependencies": [],
1655
1793
  "files": [
@@ -1667,7 +1805,7 @@
1667
1805
  "title": "Skeleton",
1668
1806
  "description": "The rule the component is built on, and the reason it takes a shape rather than filling the space:\n * **A skeleton of the wrong shape is worse than no skeleton.**\n * It promises something the content does not keep, and the promise is paid for in a jump: the page settles, the scrollbar appears, and whatever the reader was about to click has moved. Measured rather than assumed - the line's own calendar showed a list of four short lines where a six-row month grid was about to land, and the skeleton was itself the jump it existed to prevent.",
1669
1807
  "dependencies": [
1670
- "dowel-ui@^0.30.0"
1808
+ "dowel-ui@^0.31.0"
1671
1809
  ],
1672
1810
  "registryDependencies": [],
1673
1811
  "files": [
@@ -1686,7 +1824,7 @@
1686
1824
  "description": "The case for it over a NumberField is that the number does not matter much: a volume, an opacity, a weight in a search filter. Where the exact figure does matter, a slider is a worse field with more pixels - it cannot be typed into, it cannot be pasted into, and it has no state for \"empty\".",
1687
1825
  "dependencies": [
1688
1826
  "@base-ui/react",
1689
- "dowel-ui@^0.30.0"
1827
+ "dowel-ui@^0.31.0"
1690
1828
  ],
1691
1829
  "registryDependencies": [],
1692
1830
  "files": [
@@ -1705,7 +1843,7 @@
1705
1843
  "description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
1706
1844
  "dependencies": [
1707
1845
  "class-variance-authority",
1708
- "dowel-ui@^0.30.0"
1846
+ "dowel-ui@^0.31.0"
1709
1847
  ],
1710
1848
  "registryDependencies": [],
1711
1849
  "files": [
@@ -1724,7 +1862,7 @@
1724
1862
  "description": "Something is happening and the answer has not arrived. It carries no text of its own - what is loading is the product's word, not the system's - but it does have to say *something* to a screen reader, or a page that is busy is silently identical to a page that is empty.",
1725
1863
  "dependencies": [
1726
1864
  "class-variance-authority",
1727
- "dowel-ui@^0.30.0"
1865
+ "dowel-ui@^0.31.0"
1728
1866
  ],
1729
1867
  "registryDependencies": [],
1730
1868
  "files": [
@@ -1742,7 +1880,7 @@
1742
1880
  "title": "Splash",
1743
1881
  "description": "A desktop product has a second or two between the window appearing and the first screen being ready - a workspace to open, a database to migrate, a plugin to start - and a blank window for that long reads as a crash. So the window shows the product instead: the mark, the name, the promise, the version, and a bar that sweeps until there is something to draw.",
1744
1882
  "dependencies": [
1745
- "dowel-ui@^0.30.0"
1883
+ "dowel-ui@^0.31.0"
1746
1884
  ],
1747
1885
  "registryDependencies": [],
1748
1886
  "files": [
@@ -1754,6 +1892,25 @@
1754
1892
  }
1755
1893
  ]
1756
1894
  },
1895
+ {
1896
+ "name": "splitter",
1897
+ "type": "registry:ui",
1898
+ "title": "Splitter",
1899
+ "description": "The splitter primitive.",
1900
+ "dependencies": [
1901
+ "class-variance-authority",
1902
+ "dowel-ui@^0.31.0"
1903
+ ],
1904
+ "registryDependencies": [],
1905
+ "files": [
1906
+ {
1907
+ "path": "ui/splitter.tsx",
1908
+ "target": "@ui/splitter.tsx",
1909
+ "type": "registry:ui",
1910
+ "content": "import {\n Children,\n createContext,\n isValidElement,\n useContext,\n useId,\n useRef,\n useState,\n type HTMLAttributes,\n type KeyboardEvent,\n type PointerEvent as ReactPointerEvent,\n type ReactNode,\n type RefObject,\n} from 'react'\nimport { cva } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Panes side by side, or one above the other, with a handle between each two\n * that moves the boundary.\n *\n * Three parts - `Splitter`, `SplitterPane`, `SplitterHandle` - rather than a\n * component with a `first` and a `second`. A two-pane prop shape is the one\n * every product outgrows on the day it wants a third pane, and the rewrite is\n * every call site. Here a third pane is two more lines of markup. Each handle\n * moves only the boundary it sits on: the pane before it and the pane after\n * it trade size, and the others stay where they are, which is what a reader\n * dragging one edge expects.\n *\n * Sizes are percentages of the splitter, not pixels, so a window resized to\n * half its width keeps the sidebar at the same share of it instead of eating\n * the editor. A pane is laid out with its percentage as its flex grow and a\n * zero basis, which is what lets the one-pixel handles sit between them\n * without the percentages adding up to more than the box.\n *\n * The limits live on the panes (`min`, `max`, `collapsible`), because that is\n * where a reader of the markup looks for them, and the splitter reads them off\n * its children when it clamps. The price is that panes and handles must be\n * direct children - not wrapped in a fragment or a component of the product's\n * own - since that is how each learns its place in the row. The arithmetic is\n * exported as plain functions for the same reason `virtual-list`'s is: jsdom\n * has no layout, and a rule that can only be checked in a browser is a rule\n * that goes unchecked.\n *\n * The component keeps the sizes and never stores them. `onSizesChange` fires\n * when a change settles - a drag let go, a key pressed - and not on every\n * move, so a product persisting there writes once per gesture rather than\n * sixty times a second. What it saved comes back as `defaultSizes`.\n *\n * The handle is the ARIA window splitter: a focusable `separator` carrying the\n * size of the pane before it as its value, so a screen reader says how much\n * room that pane has and the arrows change it. Enter collapses a pane marked\n * `collapsible` and restores it to the size it had. The visible line is a\n * pixel; the hit area is `target-min`, the theme's named way of growing a\n * target past the floor without growing what is drawn. There is no drag by\n * HTML5 drag-and-drop, for the reason `column-resize-handle` gives: a desktop\n * shell that takes file drops never lets `dragstart` reach the page. Pointer\n * capture keeps the drag alive when the pointer runs ahead of the handle.\n */\n\n/** What a pane allows, in percent of the splitter. */\nexport interface PaneLimits {\n /** Smallest size, 0 by default. */\n min?: number\n /** Largest size, 100 by default. */\n max?: number\n /** Whether the pane can shrink past `min` to `collapsedSize`. */\n collapsible?: boolean\n /** Its size when collapsed: 0 by default, a rail's width for a pane that\n * keeps its icons. */\n collapsedSize?: number\n}\n\nconst floorOf = (limits: PaneLimits) => (limits.collapsible ? (limits.collapsedSize ?? 0) : (limits.min ?? 0))\n\n/** The range the pane before handle `index` can take, given what both panes\n * beside the handle allow. */\nexport function boundsOf(sizes: number[], index: number, limits: PaneLimits[]): [number, number] {\n const before = limits[index] ?? {}\n const after = limits[index + 1] ?? {}\n const pair = sizes[index]! + sizes[index + 1]!\n return [Math.max(floorOf(before), pair - (after.max ?? 100)), Math.min(before.max ?? 100, pair - floorOf(after))]\n}\n\n/* A collapsible pane below its minimum is either collapsed or at its minimum,\n * never between. Dragging, it goes to whichever is nearer; by keyboard the\n * direction decides, or one press past the minimum would land on the minimum\n * again and the pane could never be collapsed by arrows. */\nfunction snap(size: number, limits: PaneLimits, direction: number): number {\n const min = limits.min ?? 0\n const collapsed = limits.collapsedSize ?? 0\n if (!limits.collapsible || size >= min) return size\n return (direction === 0 ? size < (min + collapsed) / 2 : direction < 0) ? collapsed : min\n}\n\n/** New sizes after handle `index` asks for the pane before it to be `target`.\n * `direction` is the sign of a key press, or 0 for a drag. */\nexport function moveBoundary(\n sizes: number[],\n index: number,\n target: number,\n limits: PaneLimits[],\n direction = 0,\n): number[] {\n const pair = sizes[index]! + sizes[index + 1]!\n const [low, high] = boundsOf(sizes, index, limits)\n let size = snap(target, limits[index] ?? {}, direction)\n size = pair - snap(pair - size, limits[index + 1] ?? {}, -direction)\n // Two decimals: finer than a pixel on any screen, and a persisted value\n // that reads as a number rather than as float noise.\n size = Math.round(Math.min(high, Math.max(low, size)) * 100) / 100\n const next = [...sizes]\n next[index] = size\n next[index + 1] = Math.round((pair - size) * 100) / 100\n return next\n}\n\ninterface SplitterState {\n orientation: 'horizontal' | 'vertical'\n sizes: number[]\n limits: PaneLimits[]\n step: number\n paneId: (index: number) => string\n root: RefObject<HTMLDivElement | null>\n move: (index: number, target: number, direction: number, done: boolean) => void\n}\n\nconst State = createContext<SplitterState | null>(null)\nconst Place = createContext(0)\n\nfunction useSplitter() {\n const state = useContext(State)\n if (state === null) throw new Error('SplitterPane and SplitterHandle belong inside a Splitter')\n return state\n}\n\nexport interface SplitterProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** How the panes are laid out: `horizontal` is side by side, with upright\n * handles between them; `vertical` stacks them. */\n orientation?: 'horizontal' | 'vertical'\n /** One percentage per pane, summing to 100 - what was saved from\n * `onSizesChange`, or the product's opening layout. Equal shares without. */\n defaultSizes?: number[]\n /** Told the sizes when a change settles: persist them here. */\n onSizesChange?: (sizes: number[]) => void\n /** Percent moved by an arrow key; Shift moves five of these. */\n step?: number\n children: ReactNode\n}\n\nexport function Splitter({\n orientation = 'horizontal',\n defaultSizes,\n onSizesChange,\n step = 2,\n className,\n children,\n ...props\n}: SplitterProps) {\n const id = useId()\n const root = useRef<HTMLDivElement>(null)\n const limits: PaneLimits[] = []\n const placed = Children.toArray(children)\n .filter(isValidElement)\n .map((child) => {\n if (child.type === SplitterPane) limits.push(child.props as PaneLimits)\n // Counted after the push, so a pane's place is its own index and a\n // handle's is the index of the pane before it.\n return (\n <Place.Provider key={child.key} value={limits.length - 1}>\n {child}\n </Place.Provider>\n )\n })\n const even = () => limits.map(() => 100 / limits.length)\n const [kept, setKept] = useState<number[]>(() => defaultSizes ?? even())\n // A pane added or removed invalidates every share: start again from equal.\n const sizes = kept.length === limits.length ? kept : even()\n\n const move = (index: number, target: number, direction: number, done: boolean) => {\n const next = moveBoundary(sizes, index, target, limits, direction)\n setKept(next)\n if (done) onSizesChange?.(next)\n }\n\n return (\n <State.Provider\n value={{ orientation, sizes, limits, step, root, move, paneId: (index) => `${id}-pane-${index}` }}\n >\n <div\n ref={root}\n data-orientation={orientation}\n className={cn('flex size-full min-h-0 min-w-0', orientation === 'vertical' && 'flex-col', className)}\n {...props}\n >\n {placed}\n </div>\n </State.Provider>\n )\n}\n\nexport interface SplitterPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, 'id'>, PaneLimits {}\n\n// `min` and `max` are taken out only so they do not land on the `div`: the\n// splitter reads them off the element, and the pane has no use for them.\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport function SplitterPane({ min, max, collapsible, collapsedSize = 0, className, style, ...props }: SplitterPaneProps) {\n const { sizes, paneId } = useSplitter()\n const index = useContext(Place)\n const size = sizes[index] ?? 0\n return (\n <div\n id={paneId(index)}\n data-collapsed={collapsible && size <= collapsedSize ? '' : undefined}\n // A pane collapsed to nothing still holds its content, and without\n // `inert` a Tab walks into controls nobody can see.\n inert={size === 0}\n style={{ flex: `${size} 1 0px`, ...style }}\n className={cn('min-h-0 min-w-0 overflow-hidden', className)}\n {...props}\n />\n )\n}\n\nexport const splitterHandleVariants = cva(\n cn(\n 'z-10 shrink-0 touch-none select-none bg-line target-min outline-none',\n '[transition:background-color_var(--duration-quick)_var(--ease-out)]',\n 'hover:bg-accent data-[dragging]:bg-accent',\n 'focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n ),\n {\n variants: {\n /* The splitter's orientation, so the handle's line runs across it. */\n orientation: {\n horizontal: 'w-px cursor-col-resize',\n vertical: 'h-px cursor-row-resize',\n },\n },\n },\n)\n\nexport interface SplitterHandleProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** Names what the handle resizes: \"Resize the sidebar\". No default - it is\n * the product's word. */\n label: string\n}\n\nexport function SplitterHandle({ label, className, ...props }: SplitterHandleProps) {\n const { orientation, sizes, limits, step, paneId, root, move } = useSplitter()\n const index = useContext(Place)\n const origin = useRef<{ at: number; size: number; span: number } | null>(null)\n // The size a collapsed pane goes back to on the next Enter.\n const restore = useRef<number | undefined>(undefined)\n const [dragging, setDragging] = useState(false)\n const across = orientation === 'horizontal'\n const size = sizes[index] ?? 0\n const [low, high] = boundsOf(sizes, index, limits)\n\n const drag = (event: ReactPointerEvent<HTMLElement>, done: boolean) => {\n const from = origin.current\n if (from === null) return\n const travelled = (across ? event.clientX : event.clientY) - from.at\n move(index, from.size + (travelled / from.span) * 100, 0, done)\n }\n\n const begin = (event: ReactPointerEvent<HTMLElement>) => {\n // The primary button only: a right-click is the context menu's.\n if (event.button !== 0 || root.current === null) return\n const box = root.current.getBoundingClientRect()\n const span = across ? box.width : box.height\n if (span === 0) return\n event.preventDefault()\n origin.current = { at: across ? event.clientX : event.clientY, size, span }\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(true)\n }\n\n const end = (event: ReactPointerEvent<HTMLElement>) => {\n drag(event, true)\n origin.current = null\n setDragging(false)\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n }\n\n // The pane before the handle collapses if it can, else the one after it: a\n // sidebar on the right is the second pane of its handle.\n const toggled = (): number | undefined => {\n const pane = limits[index]?.collapsible ? index : limits[index + 1]?.collapsible ? index + 1 : -1\n if (pane < 0) return undefined\n const own = sizes[pane]!\n const collapsed = limits[pane]!.collapsedSize ?? 0\n let next = collapsed\n if (own <= collapsed) next = restore.current ?? Math.max(limits[pane]!.min ?? 0, 100 / sizes.length)\n else restore.current = own\n return pane === index ? next : size + sizes[index + 1]! - next\n }\n\n const press = (event: KeyboardEvent<HTMLElement>) => {\n const by = event.shiftKey ? step * 5 : step\n const target = {\n [across ? 'ArrowLeft' : 'ArrowUp']: size - by,\n [across ? 'ArrowRight' : 'ArrowDown']: size + by,\n Home: low,\n End: high,\n }[event.key] ?? (event.key === 'Enter' ? toggled() : undefined)\n if (target === undefined) return\n event.preventDefault()\n move(index, target, Math.sign(target - size), true)\n }\n\n return (\n <div\n role=\"separator\"\n tabIndex={0}\n aria-label={label}\n aria-controls={paneId(index)}\n // The line runs across the layout: panes side by side are split by an\n // upright separator.\n aria-orientation={across ? 'vertical' : 'horizontal'}\n aria-valuenow={Math.round(size)}\n aria-valuemin={Math.round(low)}\n aria-valuemax={Math.round(high)}\n data-dragging={dragging ? '' : undefined}\n onPointerDown={begin}\n onPointerMove={(event) => drag(event, false)}\n onPointerUp={end}\n onPointerCancel={end}\n onKeyDown={press}\n className={cn(splitterHandleVariants({ orientation }), className)}\n {...props}\n />\n )\n}\n"
1911
+ }
1912
+ ]
1913
+ },
1757
1914
  {
1758
1915
  "name": "stat-tile",
1759
1916
  "type": "registry:ui",
@@ -1761,7 +1918,7 @@
1761
1918
  "description": "The smallest thing on a dashboard and the one every product writes itself: a label above, a number below, sometimes a word about which way it moved.",
1762
1919
  "dependencies": [
1763
1920
  "class-variance-authority",
1764
- "dowel-ui@^0.30.0"
1921
+ "dowel-ui@^0.31.0"
1765
1922
  ],
1766
1923
  "registryDependencies": [],
1767
1924
  "files": [
@@ -1780,7 +1937,7 @@
1780
1937
  "description": "The smallest thing a screen can say about something's condition: a server is up, a job failed, a person is away. Every product of the line drew its own coloured circle, and every one of them drew it the same way - a `<span>` with a background - which means the condition existed for exactly the readers who could see it.",
1781
1938
  "dependencies": [
1782
1939
  "class-variance-authority",
1783
- "dowel-ui@^0.30.0"
1940
+ "dowel-ui@^0.31.0"
1784
1941
  ],
1785
1942
  "registryDependencies": [],
1786
1943
  "files": [
@@ -1792,6 +1949,25 @@
1792
1949
  }
1793
1950
  ]
1794
1951
  },
1952
+ {
1953
+ "name": "stepper",
1954
+ "type": "registry:ui",
1955
+ "title": "Stepper",
1956
+ "description": "The steps of a task too long for one screen, and where you stand in them: set up a workspace, import a library, configure a connection. It is a picture of progress on its own, and the top of a Wizard when the steps are a form - a product can show how far along something is without owning the form that moves it, which is why the two are separate components. Base UI has no stepper, so this one is written here rather than wrapped.",
1957
+ "dependencies": [
1958
+ "class-variance-authority",
1959
+ "dowel-ui@^0.31.0"
1960
+ ],
1961
+ "registryDependencies": [],
1962
+ "files": [
1963
+ {
1964
+ "path": "ui/stepper.tsx",
1965
+ "target": "@ui/stepper.tsx",
1966
+ "type": "registry:ui",
1967
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Stepper.\n *\n * The steps of a task too long for one screen, and where you stand in them:\n * set up a workspace, import a library, configure a connection. It is a\n * picture of progress on its own, and the top of a Wizard when the steps are\n * a form - a product can show how far along something is without owning the\n * form that moves it, which is why the two are separate components. Base UI\n * has no stepper, so this one is written here rather than wrapped.\n *\n * An ordered list, like Breadcrumbs, because the order is the meaning: a\n * reader hears \"list, 4 items\" and then the steps in the order they are taken.\n * The step you are on carries `aria-current=\"step\"`, which is what tells a\n * reader which of the four is the one in front of them rather than a list of\n * four equal names.\n *\n * **Meaning does not rest on colour.** A finished step draws a tick, a step\n * that failed its check draws an exclamation mark, the current one a filled\n * number and the ones ahead an outlined number - four shapes, so the stepper\n * reads in a greyscale screenshot and for the one man in twelve who does not\n * separate the accent from the error hue. The same states are written out for\n * a reader, in words the product gives (`stateLabels`): the component has no\n * strings of its own, because \"Completed\" is the product's vocabulary in the\n * product's language.\n *\n * **Narrow, it keeps the marks and drops the words.** A horizontal stepper in\n * a container narrower than `@xl` shows every step's mark but only the\n * current step's label; the others move off the screen and stay in the\n * accessibility tree. The marks still say how far along you are and which\n * steps are done, and `summary` - \"Step 2 of 5\", in the product's words - can\n * say it outright. Wrapping five labels onto three lines was the alternative,\n * and it turns a progress line into a paragraph. The breakpoint is the\n * container's, not the viewport's, since a wizard in a dialog is narrow on a\n * wide screen.\n *\n * **Going ahead is not a click away.** Steps are clickable only when the\n * product asks (`onStepSelect`), and even then only the steps already reached\n * - done, failed, or current. Clicking a step ahead is how a wizard's\n * validation is usually skipped: the user lands on step four with step two\n * half-filled and nothing ever checked it. `reach=\"any\"` opens the steps ahead\n * too, and is named so that choosing it is a decision.\n */\n\n/** Where a step stands. `error` is a step whose check failed - it is shown with\n * its own mark whether it is behind you or the one you are on. */\nexport type StepState = 'done' | 'current' | 'upcoming' | 'error'\n\nexport interface StepperStep {\n id: string\n label: ReactNode\n description?: ReactNode\n /**\n * What the step is, stated rather than inferred. Without it a step before\n * the current one is done and a step after it is upcoming; `done` marks a\n * step ahead that was already completed (the user went back), `error` a\n * step that failed its check.\n */\n status?: 'done' | 'error'\n}\n\n/** The words a reader hears for the states the marks draw. `current` has none:\n * `aria-current=\"step\"` already says it, in the reader's own language. */\nexport interface StepStateLabels {\n done: string\n error: string\n /** Optional, because an unmarked step reads as not yet reached. */\n upcoming?: string\n}\n\n/** The mark in front of each label. Every state differs in shape as well as in\n * hue - a tick, a mark, a filled number, an outlined number - which is the\n * rule the whole component rests on. */\nexport const stepMarkerVariants = cva(\n [\n 'inline-flex shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',\n 'transition-colors duration-(--duration-base) ease-(--ease-out)',\n ],\n {\n variants: {\n state: {\n done: 'bg-accent-soft text-accent',\n current: 'bg-accent text-on-accent',\n upcoming: 'border border-line-2 text-dim',\n error: 'bg-bad-soft text-bad',\n },\n },\n defaultVariants: { state: 'upcoming' },\n },\n)\n\nexport interface StepperProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called for a reader - \"Setup steps\". */\n label: string\n steps: readonly StepperStep[]\n /** The id of the step being shown. */\n current: string\n /** The words for the states, read by a screen reader beside each label. */\n stateLabels: StepStateLabels\n /**\n * The position in words - `(2, 5) => 'Step 2 of 5'`. Shown only when a\n * horizontal stepper is too narrow for its labels. A function of the two\n * numbers rather than a string, because the product says it in its own\n * language and grammar.\n */\n summary?: (position: number, total: number) => ReactNode\n /** Makes reachable steps buttons. Without it the stepper is a picture of\n * progress and nothing in it is a control. */\n onStepSelect?: (id: string) => void\n /**\n * Which steps can be clicked. `visited` - the default - is done, failed and\n * current ones; `any` also opens the ones ahead, which skips whatever check\n * stands between here and there. Only a product that has made the steps\n * independent should choose it.\n */\n reach?: 'visited' | 'any'\n /** A row across the top, or a column down the side. Only the row folds its\n * labels away when narrow; a column has the height to keep them. */\n orientation?: 'horizontal' | 'vertical'\n}\n\n/** A step's state from its place and from what the product said about it. */\nexport function stepState(step: StepperStep, index: number, currentIndex: number): StepState {\n if (step.status === 'error') return 'error'\n if (index === currentIndex) return 'current'\n if (step.status === 'done' || index < currentIndex) return 'done'\n return 'upcoming'\n}\n\nexport function Stepper({\n label,\n steps,\n current,\n stateLabels,\n summary,\n onStepSelect,\n reach = 'visited',\n orientation = 'horizontal',\n className,\n ...props\n}: StepperProps) {\n const horizontal = orientation === 'horizontal'\n const currentIndex = steps.findIndex((step) => step.id === current)\n\n const list = (\n <>\n {horizontal && summary && currentIndex >= 0 && (\n // Only in the narrow form, where the labels have gone and the position\n // needs saying. `hidden` rather than `sr-only` in the wide form: there\n // the list says it, and a reader hearing it twice is hearing noise.\n <p className=\"mb-2 text-xs text-dim @xl:hidden\">{summary(currentIndex + 1, steps.length)}</p>\n )}\n <ol\n className={cn(\n 'flex',\n horizontal ? 'items-center gap-2' : 'flex-col',\n )}\n >\n {steps.map((step, index) => {\n const state = stepState(step, index, currentIndex)\n const isCurrent = index === currentIndex\n const reachable = reach === 'any' || index <= currentIndex || state !== 'upcoming'\n return (\n <StepperItem\n key={step.id}\n step={step}\n index={index}\n state={state}\n isCurrent={isCurrent}\n last={index === steps.length - 1}\n horizontal={horizontal}\n stateLabels={stateLabels}\n onSelect={onStepSelect && reachable ? () => onStepSelect(step.id) : undefined}\n />\n )\n })}\n </ol>\n </>\n )\n\n // A landmark only when there is somewhere to go. A stepper nobody can click\n // is a progress picture, and announcing it as navigation would promise a\n // reader controls that are not there.\n return onStepSelect ? (\n <nav aria-label={label} className={cn('@container min-w-0', className)} {...props}>\n {list}\n </nav>\n ) : (\n <div\n role=\"group\"\n aria-label={label}\n className={cn('@container min-w-0', className)}\n {...props}\n >\n {list}\n </div>\n )\n}\n\nfunction StepperItem({\n step,\n index,\n state,\n isCurrent,\n last,\n horizontal,\n stateLabels,\n onSelect,\n}: {\n step: StepperStep\n index: number\n state: StepState\n isCurrent: boolean\n last: boolean\n horizontal: boolean\n stateLabels: StepStateLabels\n onSelect?: () => void\n}) {\n const stateWord =\n state === 'done' ? stateLabels.done : state === 'error' ? stateLabels.error : state === 'upcoming' ? stateLabels.upcoming : undefined\n\n // Narrow and horizontal, only the current step keeps its words on the\n // screen. The others go to `sr-only`, not `hidden`: a reader still hears\n // every step.\n const collapsible = horizontal && !isCurrent\n const text = (\n <span className={cn('flex min-w-0 flex-col', collapsible && 'sr-only @xl:not-sr-only')}>\n <span\n className={cn(\n 'truncate text-sm',\n isCurrent ? 'font-semibold text-text' : 'text-dim',\n state === 'error' && 'text-bad',\n )}\n >\n {step.label}\n </span>\n {step.description !== undefined && (\n <span className={cn('text-xs text-faint', horizontal && 'sr-only @xl:not-sr-only @xl:truncate')}>\n {step.description}\n </span>\n )}\n {stateWord && <span className=\"sr-only\">{stateWord}</span>}\n </span>\n )\n\n const glyph =\n state === 'done' ? <Tick /> : state === 'error' ? <Mark /> : <span>{index + 1}</span>\n\n const itemClass = cn(\n 'flex min-w-0 items-start gap-2 rounded-md text-left',\n horizontal ? 'items-center' : 'items-start',\n )\n\n const item = onSelect ? (\n <button\n type=\"button\"\n aria-current={isCurrent ? 'step' : undefined}\n onClick={onSelect}\n className={cn(\n itemClass,\n 'cursor-pointer py-1 pr-1 transition-colors duration-(--duration-quick) hover:bg-soft',\n 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <span aria-hidden className={cn('size-6', stepMarkerVariants({ state }))}>\n {glyph}\n </span>\n {text}\n </button>\n ) : (\n <span aria-current={isCurrent ? 'step' : undefined} className={cn(itemClass, 'py-1')}>\n <span aria-hidden className={cn('size-6', stepMarkerVariants({ state }))}>\n {glyph}\n </span>\n {text}\n </span>\n )\n\n // The line joining a step to the next: accent once the step is behind you,\n // so the run of finished steps reads as one stroke. Drawing, not structure -\n // the list already says the steps are in order.\n const done = state === 'done'\n const connector = last ? null : (\n <span\n aria-hidden\n className={cn(\n 'transition-colors duration-(--duration-base) ease-(--ease-out)',\n done ? 'bg-accent' : 'bg-line',\n horizontal ? 'h-px min-w-3 flex-1' : 'absolute top-8 bottom-0 left-3 w-px',\n )}\n />\n )\n\n return (\n <li\n className={cn(\n horizontal\n ? cn('flex min-w-0 items-center gap-2', last ? 'shrink-0' : 'flex-1', isCurrent && 'shrink')\n : cn('relative', !last && 'pb-4'),\n )}\n >\n {item}\n {connector}\n </li>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" aria-hidden>\n <path d=\"M3.5 8.5l3 3 6-7\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n\nfunction Mark() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" aria-hidden>\n <path d=\"M8 3.5v5.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n <circle cx=\"8\" cy=\"12.25\" r=\"1.25\" fill=\"currentColor\" />\n </svg>\n )\n}\n"
1968
+ }
1969
+ ]
1970
+ },
1795
1971
  {
1796
1972
  "name": "switch",
1797
1973
  "type": "registry:ui",
@@ -1799,7 +1975,7 @@
1799
1975
  "description": "The difference from Checkbox is not how it looks, and getting it wrong is the commonest mistake in the pair. A checkbox is an answer collected now and submitted later, with the rest of the form; a switch is a setting that applies the moment it moves. Put a switch in a form with a Save button and the reader cannot tell whether anything happened - they flipped it, and nothing said so.",
1800
1976
  "dependencies": [
1801
1977
  "@base-ui/react",
1802
- "dowel-ui@^0.30.0"
1978
+ "dowel-ui@^0.31.0"
1803
1979
  ],
1804
1980
  "registryDependencies": [],
1805
1981
  "files": [
@@ -1823,7 +1999,7 @@
1823
1999
  "path": "ui/table-sort.tsx",
1824
2000
  "target": "@ui/table-sort.tsx",
1825
2001
  "type": "registry:ui",
1826
- "content": "/*\n * How a column is ordered, with no React in it.\n *\n * Split out of the Table for the reason `calendar-math` was split out of the\n * Calendar: these are the sums, and the component is the thing that draws\n * them. A product sorting its own rows - on the server, in a worker, before\n * the data ever reaches a component - imports this and nothing else.\n *\n * No table library, deliberately. The obvious choice here is TanStack Table,\n * and it would be the first dependency a product has to install beyond Base\n * UI. What it offers is a model of columns, pages and sorting state; what the\n * line's one real table needed was the rule below, which the model does not\n * have. So the model is the part that is written here, and it is small.\n *\n * The rule, and the reason this file exists at all:\n *\n * **Absence sorts last, whichever way the column points.**\n *\n * A row with no value in this column is not the smallest - it is unknown, and\n * the two are different facts. Rank absence with the rest and flip the sign,\n * and a descending sort floats every empty row to the top: the reader asks for\n * \"highest first\" and is handed the rows that have no value at all. This is\n * measured rather than assumed - it is what the first version of the line's\n * catalogue did, and the fix is the shape below, where presence is settled\n * before the direction is applied.\n *\n * It is the same fact `RatingScale` is built on: not judged yet is a state,\n * not a zero. A table that sorts them together loses it on the first click.\n */\n\n/** Which way a column points. */\nexport type SortDirection = 'asc' | 'desc'\n\n/** A column, and which way it points. `column` is the caller's own key - the\n * id it gave the column, not an index, so reordering columns cannot silently\n * change what is sorted. */\nexport interface Sort<Column extends string = string> {\n column: Column\n direction: SortDirection\n}\n\n/** What can be compared. `null` and `undefined` both mean absent - a product\n * gets whichever its data uses, and having to normalise them before sorting\n * is the kind of step that gets forgotten in one branch of a switch. */\nexport type SortValue = string | number | boolean | null | undefined\n\n/** Reads the value of a column out of a row.\n *\n * Given rather than inferred: a column id is not always a field name, and a\n * sortable column is often a computed one - a duration that is stored as two\n * timestamps, a name that sorts by surname. */\nexport type SortAccessor<Row, Column extends string = string> = (\n row: Row,\n column: Column,\n) => SortValue\n\nexport interface SortOptions<Row> {\n /** The tiebreaker: what decides the order of rows that compare equal.\n *\n * Without one, `Array.prototype.sort` is stable and therefore leaves ties in\n * input order - which sounds fine until the input is re-fetched and arrives\n * in a different order, and a column of ties reshuffles under the reader\n * with no click. Pass the row's id. */\n tiebreak?: (row: Row) => SortValue\n /** The locale text is compared in. Passed to `Intl.Collator`, so `ä` sorts\n * where the reader expects rather than after `z`. */\n locale?: string\n}\n\n/** Order the rows. Returns a new array; the input is not touched, because a\n * component that sorts its own prop in place mutates the caller's state. */\nexport function sortRows<Row, Column extends string = string>(\n rows: readonly Row[],\n sort: Sort<Column>,\n accessor: SortAccessor<Row, Column>,\n options: SortOptions<Row> = {},\n): Row[] {\n const sign = sort.direction === 'asc' ? 1 : -1\n // One collator for the whole sort rather than one `localeCompare` per\n // comparison: building it is the expensive half, and a sort of n rows asks\n // for it n log n times.\n const collator = new Intl.Collator(options.locale)\n\n return [...rows].sort((a, b) => {\n const left = accessor(a, sort.column)\n const right = accessor(b, sort.column)\n\n // Presence first, and outside the sign. This is the whole point of the\n // file: absence is last in both directions.\n const absent = presence(left, right)\n if (absent !== 0) return absent\n\n const ranked = compareValues(left, right, collator) * sign\n if (ranked !== 0) return ranked\n\n if (options.tiebreak) {\n const tied = compareValues(options.tiebreak(a), options.tiebreak(b), collator)\n // Deliberately not multiplied by the sign. The tiebreaker is there to\n // make the order stable, and an order that reverses with the column is\n // not stable - the rows that tie would swap places on every click.\n if (tied !== 0) return tied\n }\n return 0\n })\n}\n\n/** Whether a value counts as absent.\n *\n * Both spellings of nothing, and neither `0` nor `''` nor `false`, which are\n * values a row genuinely has.\n *\n * `NaN` counts too, and that is not tidiness. Subtraction with it returns\n * `NaN`, which `sort` reads as \"these two are equal\" - so a row whose number\n * is not a number takes whatever place the input happened to give it, and the\n * order changes when the data is re-fetched. A number that is not a number is\n * not a small number; it is unknown, which is what this file already has a\n * place for. */\nexport function isAbsent(value: SortValue): boolean {\n if (value === null || value === undefined) return true\n return typeof value === 'number' && Number.isNaN(value)\n}\n\n/** Which of the two lacks a value; absent sorts last, always. */\nfunction presence(left: SortValue, right: SortValue): number {\n const leftHas = !isAbsent(left)\n const rightHas = !isAbsent(right)\n if (leftHas === rightHas) return 0\n return leftHas ? -1 : 1\n}\n\n/** Compare two present values of the same column.\n *\n * Numbers by subtraction, text by collator, and booleans as false-then-true.\n * A column whose values are of mixed type compares as text: that is a data\n * problem the table cannot fix, and ordering by `String` at least gives the\n * same answer twice. */\nfunction compareValues(left: SortValue, right: SortValue, collator: Intl.Collator): number {\n if (isAbsent(left) || isAbsent(right)) return presence(left, right)\n\n // NaN never reaches here: `isAbsent` counts it as absent, so `presence`\n // settles it above, outside the sign.\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') {\n return Number(left) - Number(right)\n }\n return collator.compare(String(left), String(right))\n}\n\n/** What clicking a column heading does.\n *\n * Three states rather than two, and the third is the reason this is a function\n * and not `direction === 'asc' ? 'desc' : 'asc'`: clicking a *different*\n * column starts it ascending rather than inheriting the direction of the one\n * before. Inheriting is what a two-state toggle does, and it means the first\n * click on a new column can hand back an order nobody asked for. */\nexport function toggleSort<Column extends string>(\n sort: Sort<Column>,\n column: Column,\n): Sort<Column> {\n if (sort.column !== column) return { column, direction: 'asc' }\n return { column, direction: sort.direction === 'asc' ? 'desc' : 'asc' }\n}\n\n/** What a column heading announces: `ascending`, `descending`, or nothing.\n *\n * The value belongs in `aria-sort` on the `<th>`, and only on the one that is\n * sorted - `aria-sort=\"none\"` on every other heading is allowed by the spec\n * and read out by some screen readers on every cell, which turns a table into\n * a recital. `undefined` removes the attribute. */\nexport function ariaSort(\n sort: Sort<string> | undefined,\n column: string,\n): 'ascending' | 'descending' | undefined {\n if (!sort || sort.column !== column) return undefined\n return sort.direction === 'asc' ? 'ascending' : 'descending'\n}\n"
2002
+ "content": "/*\n * How a column is ordered, with no React in it.\n *\n * Split out of the Table for the reason `calendar-math` was split out of the\n * Calendar: these are the sums, and the component is the thing that draws\n * them. A product sorting its own rows - on the server, in a worker, before\n * the data ever reaches a component - imports this and nothing else.\n *\n * No table library, deliberately. The obvious choice here is TanStack Table,\n * and it would be the first dependency a product has to install beyond Base\n * UI. What it offers is a model of columns, pages and sorting state; what the\n * line's one real table needed was the rule below, which the model does not\n * have. So the model is the part that is written here, and it is small.\n *\n * The rule, and the reason this file exists at all:\n *\n * **Absence sorts last, whichever way the column points.**\n *\n * A row with no value in this column is not the smallest - it is unknown, and\n * the two are different facts. Rank absence with the rest and flip the sign,\n * and a descending sort floats every empty row to the top: the reader asks for\n * \"highest first\" and is handed the rows that have no value at all. This is\n * measured rather than assumed - it is what the first version of the line's\n * catalogue did, and the fix is the shape below, where presence is settled\n * before the direction is applied.\n *\n * It is the same fact `RatingScale` is built on: not judged yet is a state,\n * not a zero. A table that sorts them together loses it on the first click.\n */\n\n/** Which way a column points. */\nexport type SortDirection = 'asc' | 'desc'\n\n/** A column, and which way it points. `column` is the caller's own key - the\n * id it gave the column, not an index, so reordering columns cannot silently\n * change what is sorted. */\nexport interface Sort<Column extends string = string> {\n column: Column\n direction: SortDirection\n}\n\n/** What can be compared. `null` and `undefined` both mean absent - a product\n * gets whichever its data uses, and having to normalise them before sorting\n * is the kind of step that gets forgotten in one branch of a switch. */\nexport type SortValue = string | number | boolean | null | undefined\n\n/** Reads the value of a column out of a row.\n *\n * Given rather than inferred: a column id is not always a field name, and a\n * sortable column is often a computed one - a duration that is stored as two\n * timestamps, a name that sorts by surname. */\nexport type SortAccessor<Row, Column extends string = string> = (\n row: Row,\n column: Column,\n) => SortValue\n\nexport interface SortOptions<Row> {\n /** The tiebreaker: what decides the order of rows that compare equal.\n *\n * Without one, `Array.prototype.sort` is stable and therefore leaves ties in\n * input order - which sounds fine until the input is re-fetched and arrives\n * in a different order, and a column of ties reshuffles under the reader\n * with no click. Pass the row's id. */\n tiebreak?: (row: Row) => SortValue\n /** The language text is compared in - the application's, from\n * `useLocale()`. Passed to `Intl.Collator`, so `ä` sorts where the reader\n * expects rather than after `z`. Required: left out, the collator used the\n * browser's language, which is not the one the table is written in. */\n locale: string\n}\n\n/** Order the rows. Returns a new array; the input is not touched, because a\n * component that sorts its own prop in place mutates the caller's state. */\nexport function sortRows<Row, Column extends string = string>(\n rows: readonly Row[],\n sort: Sort<Column>,\n accessor: SortAccessor<Row, Column>,\n options: SortOptions<Row>,\n): Row[] {\n const sign = sort.direction === 'asc' ? 1 : -1\n // One collator for the whole sort rather than one `localeCompare` per\n // comparison: building it is the expensive half, and a sort of n rows asks\n // for it n log n times.\n const collator = new Intl.Collator(options.locale)\n\n return [...rows].sort((a, b) => {\n const left = accessor(a, sort.column)\n const right = accessor(b, sort.column)\n\n // Presence first, and outside the sign. This is the whole point of the\n // file: absence is last in both directions.\n const absent = presence(left, right)\n if (absent !== 0) return absent\n\n const ranked = compareValues(left, right, collator) * sign\n if (ranked !== 0) return ranked\n\n if (options.tiebreak) {\n const tied = compareValues(options.tiebreak(a), options.tiebreak(b), collator)\n // Deliberately not multiplied by the sign. The tiebreaker is there to\n // make the order stable, and an order that reverses with the column is\n // not stable - the rows that tie would swap places on every click.\n if (tied !== 0) return tied\n }\n return 0\n })\n}\n\n/** Whether a value counts as absent.\n *\n * Both spellings of nothing, and neither `0` nor `''` nor `false`, which are\n * values a row genuinely has.\n *\n * `NaN` counts too, and that is not tidiness. Subtraction with it returns\n * `NaN`, which `sort` reads as \"these two are equal\" - so a row whose number\n * is not a number takes whatever place the input happened to give it, and the\n * order changes when the data is re-fetched. A number that is not a number is\n * not a small number; it is unknown, which is what this file already has a\n * place for. */\nexport function isAbsent(value: SortValue): boolean {\n if (value === null || value === undefined) return true\n return typeof value === 'number' && Number.isNaN(value)\n}\n\n/** Which of the two lacks a value; absent sorts last, always. */\nfunction presence(left: SortValue, right: SortValue): number {\n const leftHas = !isAbsent(left)\n const rightHas = !isAbsent(right)\n if (leftHas === rightHas) return 0\n return leftHas ? -1 : 1\n}\n\n/** Compare two present values of the same column.\n *\n * Numbers by subtraction, text by collator, and booleans as false-then-true.\n * A column whose values are of mixed type compares as text: that is a data\n * problem the table cannot fix, and ordering by `String` at least gives the\n * same answer twice. */\nfunction compareValues(left: SortValue, right: SortValue, collator: Intl.Collator): number {\n if (isAbsent(left) || isAbsent(right)) return presence(left, right)\n\n // NaN never reaches here: `isAbsent` counts it as absent, so `presence`\n // settles it above, outside the sign.\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') {\n return Number(left) - Number(right)\n }\n return collator.compare(String(left), String(right))\n}\n\n/** What clicking a column heading does.\n *\n * Three states rather than two, and the third is the reason this is a function\n * and not `direction === 'asc' ? 'desc' : 'asc'`: clicking a *different*\n * column starts it ascending rather than inheriting the direction of the one\n * before. Inheriting is what a two-state toggle does, and it means the first\n * click on a new column can hand back an order nobody asked for. */\nexport function toggleSort<Column extends string>(\n sort: Sort<Column>,\n column: Column,\n): Sort<Column> {\n if (sort.column !== column) return { column, direction: 'asc' }\n return { column, direction: sort.direction === 'asc' ? 'desc' : 'asc' }\n}\n\n/** What a column heading announces: `ascending`, `descending`, or nothing.\n *\n * The value belongs in `aria-sort` on the `<th>`, and only on the one that is\n * sorted - `aria-sort=\"none\"` on every other heading is allowed by the spec\n * and read out by some screen readers on every cell, which turns a table into\n * a recital. `undefined` removes the attribute. */\nexport function ariaSort(\n sort: Sort<string> | undefined,\n column: string,\n): 'ascending' | 'descending' | undefined {\n if (!sort || sort.column !== column) return undefined\n return sort.direction === 'asc' ? 'ascending' : 'descending'\n}\n"
1827
2003
  }
1828
2004
  ]
1829
2005
  },
@@ -1834,7 +2010,7 @@
1834
2010
  "description": "Parts rather than a `columns` prop, and that is the decision worth stating: a `<DataTable columns={…} rows={…} />` is quicker to write for the first table and then owns every cell in the product forever. The moment one column needs a Badge, another a link, and a third the row's own menu, the prop grows a `render` for each - at which point it is JSX with extra steps, spelt in a shape only this component understands.",
1835
2011
  "dependencies": [
1836
2012
  "class-variance-authority",
1837
- "dowel-ui@^0.30.0"
2013
+ "dowel-ui@^0.31.0"
1838
2014
  ],
1839
2015
  "registryDependencies": [
1840
2016
  "https://lacodda.github.io/dowel/r/table-sort.json"
@@ -1848,6 +2024,26 @@
1848
2024
  }
1849
2025
  ]
1850
2026
  },
2027
+ {
2028
+ "name": "tabs",
2029
+ "type": "registry:ui",
2030
+ "title": "Tabs",
2031
+ "description": "The tabs primitive.",
2032
+ "dependencies": [
2033
+ "@base-ui/react",
2034
+ "class-variance-authority",
2035
+ "dowel-ui@^0.31.0"
2036
+ ],
2037
+ "registryDependencies": [],
2038
+ "files": [
2039
+ {
2040
+ "path": "ui/tabs.tsx",
2041
+ "target": "@ui/tabs.tsx",
2042
+ "type": "registry:ui",
2043
+ "content": "import { createContext, useContext, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'\nimport { Tabs as Base } from '@base-ui/react/tabs'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tabs - one place on a screen showing one of several views, or a window\n * showing one of several open documents.\n *\n * Two shapes, because the line has two needs and they look nothing alike.\n * `line` is the familiar row of words over a panel, the active one underlined\n * in the accent: settings split into sections, a record's details and its\n * history. `bar` is the strip of open documents that lives *inside* a\n * frameless window's title bar - scheda's trade, where a separate tab row under\n * a system title bar cost about sixty pixels of every laptop screen. A bar tab\n * is as tall as the bar, the active one takes the page's ground so it reads as\n * joined to the document below, and a two-pixel accent rule on its top edge\n * marks it where an underline would sit against the window's edge.\n *\n * Behaviour is Base UI's: arrow keys move between tabs, Home and End go to\n * the ends, the list loops, and the panel is wired to its tab with the ids a\n * reader needs. Nothing here re-implements that.\n *\n * A document tab can close, and that is where the obvious markup is wrong.\n * A close button *inside* a `tab` is an interactive element nested in another,\n * which a screen reader flattens into one control and a keyboard cannot reach\n * separately. So the cross is a sibling drawn over the tab, for the pointer\n * only - taken out of the tab order and the reading - and the keyboard closes\n * the focused tab with Delete, which the tab announces through\n * `aria-keyshortcuts`. A middle click closes too, the way every tabbed thing\n * does. The cross is always drawn, faintly, rather than revealed on hover: a\n * button that appears under the pointer is one the pointer was not aiming for.\n *\n * A document with unsaved changes carries a dot in the accent. The dot is\n * drawing; the words `modifiedLabel` gives are what a reader hears, and the\n * type will not accept one without the other.\n */\n\nexport function Tabs({ className, ...props }: Base.Root.Props) {\n return <Base.Root className={cn('flex min-w-0 flex-col data-[orientation=vertical]:flex-row', className)} {...props} />\n}\n\nexport const tabsListVariants = cva('relative flex min-w-0', {\n variants: {\n variant: {\n line: 'gap-1 border-b border-line data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-r data-[orientation=vertical]:border-b-0',\n // The strip scrolls sideways rather than squeezing: a window with twenty\n // documents open keeps every name legible and lets the bar scroll. The\n // scrollbar itself stays out of a forty-pixel bar.\n bar: 'h-full items-stretch overflow-x-auto [scrollbar-width:none]',\n },\n },\n defaultVariants: { variant: 'line' },\n})\n\nexport interface TabsListProps extends Base.List.Props, VariantProps<typeof tabsListVariants> {\n /** A name for the list, when nothing on the screen already says what the\n * tabs choose between. Documents in a window bar need one. */\n 'aria-label'?: string\n}\n\ntype Variant = NonNullable<TabsListProps['variant']>\n\n/* The list tells its tabs which shape they are, through context rather than a\n * CSS ancestor selector: a panel of `line` tabs inside a `bar` document would\n * match the outer list's selector too. */\nconst VariantContext = createContext<Variant>('line')\n\nexport function TabsList({ variant, className, children, ...props }: TabsListProps) {\n const shape: Variant = variant ?? 'line'\n return (\n <Base.List className={cn(tabsListVariants({ variant: shape }), className)} {...props}>\n <VariantContext.Provider value={shape}>{children}</VariantContext.Provider>\n {shape === 'line' && (\n <Base.Indicator\n className={cn(\n // Base UI measures the active tab and hands its box over as CSS\n // variables, so the rule slides between tabs rather than jumping.\n 'absolute bottom-0 left-(--active-tab-left) h-0.5 w-(--active-tab-width) translate-y-px bg-accent',\n 'transition-[left,width] duration-quick ease-out',\n 'data-[orientation=vertical]:top-(--active-tab-top) data-[orientation=vertical]:right-0 data-[orientation=vertical]:bottom-auto data-[orientation=vertical]:left-auto',\n 'data-[orientation=vertical]:h-(--active-tab-height) data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:translate-x-px data-[orientation=vertical]:translate-y-0',\n 'data-[orientation=vertical]:transition-[top,height]',\n )}\n />\n )}\n </Base.List>\n )\n}\n\nconst tabBase = cn(\n 'flex cursor-default items-center gap-1.5 whitespace-nowrap text-dim outline-none select-none',\n 'transition-colors duration-quick',\n 'hover:text-text data-[active]:text-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',\n)\n\n/** The shape of a tab, told by the list it sits in. */\nconst tabShape: Record<Variant, string> = {\n line: 'h-9 rounded-md px-3 text-sm',\n bar: cn(\n 'h-full border-r border-line pl-3 text-xs',\n 'hover:bg-soft data-[active]:bg-bg data-[active]:shadow-[inset_0_2px_0_var(--accent)]',\n ),\n}\n\ntype Modified =\n | { modified?: false; modifiedLabel?: never }\n | {\n /** The document has changes that are not saved. */\n modified: true\n /** What a reader hears for the dot, e.g. \"unsaved changes\". */\n modifiedLabel: string\n }\n\ntype Closable =\n | { onClose?: never; closeLabel?: never }\n | {\n /** Closes the tab: from the cross, a middle click, or Delete. */\n onClose: () => void\n /** The cross's tooltip, e.g. \"Close notes.md\". */\n closeLabel: string\n }\n\nexport type TabsTabProps = Omit<Base.Tab.Props, 'children'> & {\n children: ReactNode\n} & Modified &\n Closable\n\nexport function TabsTab({\n className,\n children,\n modified,\n modifiedLabel,\n onClose,\n closeLabel,\n onKeyDown,\n onAuxClick,\n ...props\n}: TabsTabProps) {\n const variant = useContext(VariantContext)\n const tab = (\n <Base.Tab\n className={cn(tabBase, tabShape[variant], onClose && (variant === 'bar' ? 'pr-8' : 'pr-9'), className)}\n aria-keyshortcuts={onClose ? 'Delete' : undefined}\n onKeyDown={(event: KeyboardEvent<HTMLElement>) => {\n onKeyDown?.(event as Parameters<NonNullable<typeof onKeyDown>>[0])\n if (onClose && event.key === 'Delete' && !event.defaultPrevented) {\n event.preventDefault()\n onClose()\n }\n }}\n onAuxClick={(event: MouseEvent<HTMLElement>) => {\n onAuxClick?.(event as Parameters<NonNullable<typeof onAuxClick>>[0])\n if (onClose && event.button === 1) {\n event.preventDefault()\n onClose()\n }\n }}\n {...props}\n >\n <span className=\"min-w-0 max-w-56 truncate\">{children}</span>\n {modified && (\n <>\n <span aria-hidden className=\"size-1.5 shrink-0 rounded-full bg-accent\" />\n {/* A space of its own, outside the spans: name computation trims\n each element's text, so a space inside one is lost and the\n reader hears \"plan.mdunsaved changes\". */}\n {' '}\n <span className=\"sr-only\">{modifiedLabel}</span>\n </>\n )}\n </Base.Tab>\n )\n\n if (!onClose) return tab\n\n return (\n <span className=\"relative flex shrink-0\">\n {tab}\n <button\n type=\"button\"\n tabIndex={-1}\n aria-hidden\n title={closeLabel}\n onClick={onClose}\n className={cn(\n 'target-min absolute top-1/2 right-2 flex size-4 -translate-y-1/2 cursor-default items-center justify-center rounded-sm',\n 'text-faint transition-colors duration-quick hover:bg-soft hover:text-text',\n )}\n >\n <svg width=\"8\" height=\"8\" viewBox=\"0 0 8 8\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" aria-hidden>\n <path d=\"M0.5 0.5l7 7M7.5 0.5l-7 7\" />\n </svg>\n </button>\n </span>\n )\n}\n\nexport function TabsPanel({ className, ...props }: Base.Panel.Props) {\n return (\n <Base.Panel\n className={cn(\n 'min-w-0 flex-1 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n"
2044
+ }
2045
+ ]
2046
+ },
1851
2047
  {
1852
2048
  "name": "tag-input",
1853
2049
  "type": "registry:ui",
@@ -1855,7 +2051,7 @@
1855
2051
  "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
1856
2052
  "dependencies": [
1857
2053
  "class-variance-authority",
1858
- "dowel-ui@^0.30.0"
2054
+ "dowel-ui@^0.31.0"
1859
2055
  ],
1860
2056
  "registryDependencies": [
1861
2057
  "https://lacodda.github.io/dowel/r/chip.json",
@@ -1876,7 +2072,7 @@
1876
2072
  "title": "Textarea",
1877
2073
  "description": "A multi-line field that can grow with what is typed into it, which is the only interesting part: a fixed box makes someone scroll inside a scroll, and a box that grows without limit pushes the button they are trying to reach off the screen. `autoResize` grows it; `maxRows` says when to stop and let it scroll after all.",
1878
2074
  "dependencies": [
1879
- "dowel-ui@^0.30.0"
2075
+ "dowel-ui@^0.31.0"
1880
2076
  ],
1881
2077
  "registryDependencies": [
1882
2078
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1897,7 +2093,7 @@
1897
2093
  "description": "The tier primitive.",
1898
2094
  "dependencies": [
1899
2095
  "class-variance-authority",
1900
- "dowel-ui@^0.30.0"
2096
+ "dowel-ui@^0.31.0"
1901
2097
  ],
1902
2098
  "registryDependencies": [],
1903
2099
  "files": [
@@ -1915,7 +2111,7 @@
1915
2111
  "title": "TimeField",
1916
2112
  "description": "No donor for this one: neither product of the line had a time field, so this is written from the same shape as DurationField, and for the same reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`, `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.",
1917
2113
  "dependencies": [
1918
- "dowel-ui@^0.30.0"
2114
+ "dowel-ui@^0.31.0"
1919
2115
  ],
1920
2116
  "registryDependencies": [
1921
2117
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1925,7 +2121,7 @@
1925
2121
  "path": "ui/time-field.tsx",
1926
2122
  "target": "@ui/time-field.tsx",
1927
2123
  "type": "registry:ui",
1928
- "content": "import { useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * TimeField - a time of day, typed the way people say it.\n *\n * No donor for this one: neither product of the line had a time field, so\n * this is written from the same shape as DurationField, and for the same\n * reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`,\n * `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.\n *\n * The value is `HH:MM` in twenty-four hours, always, no matter how it was\n * typed or how it is shown. That is what a database column holds and what\n * sorts correctly as a string; whether the reader sees `9:30 PM` or `21:30`\n * is a matter of where they live, and `Intl` answers it.\n *\n * Not `<input type=\"time\">`, and the reason is the same as NumberField's: the\n * browser draws its own control, its own spinner and its own clock popup,\n * none of which a stylesheet reaches - so a form of the product's own fields\n * gets one that is visibly not.\n *\n * Empty is `null`, like the other fields here: no time is not midnight.\n */\n\n/** Minutes since midnight from whatever was typed, or `null` for empty, or\n * `undefined` when it cannot be read as a time.\n *\n * Exported because the parsing is the component - a test that types into the\n * box checks React's state handling, and what has to be right is this. */\nexport function parseTime(text: string): string | null | undefined {\n const input = text.trim().toLowerCase().replace(/\\s+/g, '')\n if (input === '') return null\n\n // `pm` means add twelve hours, `am` means midnight is 12. Stripped first so\n // the rest of the parsing does not have to know about them.\n const meridiem = /(am|pm)$/.exec(input)?.[1]\n const body = meridiem ? input.slice(0, -2) : input\n\n let hours: number\n let minutes: number\n\n const separated = /^(\\d{1,2})[:.](\\d{2})$/.exec(body)\n if (separated) {\n hours = Number(separated[1])\n minutes = Number(separated[2])\n } else if (/^\\d{1,2}$/.test(body)) {\n // A bare number is an hour: `9` is nine o'clock, not nine minutes past\n // midnight - which is what someone typing a time means.\n hours = Number(body)\n minutes = 0\n } else if (/^\\d{3,4}$/.test(body)) {\n // `930` and `0930`, which is how a time gets typed when the colon is a\n // reach on a phone keyboard.\n hours = Number(body.slice(0, body.length - 2))\n minutes = Number(body.slice(-2))\n } else {\n return undefined\n }\n\n if (minutes > 59) return undefined\n\n if (meridiem) {\n if (hours < 1 || hours > 12) return undefined\n if (meridiem === 'pm' && hours !== 12) hours += 12\n if (meridiem === 'am' && hours === 12) hours = 0\n } else if (hours > 23) {\n return undefined\n }\n\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`\n}\n\n/** How a time reads here: `21:30` in most of the world, `9:30 PM` in some of\n * it. The stored value does not change - only what is shown. */\nexport function formatTime(time: string, locale?: string): string {\n const [hours, minutes] = time.split(':').map(Number) as [number, number]\n return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(\n new Date(2024, 0, 1, hours, minutes),\n )\n}\n\nexport interface TimeFieldProps {\n /** `HH:MM` in twenty-four hours, or `null` for empty. */\n value: string | null\n onValueChange: (value: string | null) => void\n /** How the time is shown while the field is not being typed into. The\n * reader's own unless stated. */\n locale?: string\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function TimeField({\n value,\n onValueChange,\n locale,\n className,\n ref,\n ...props\n}: TimeFieldProps) {\n const display = (time: string | null) => (time === null ? '' : formatTime(time, locale))\n\n /* Text while it is being typed, a formatted time the rest of the time -\n * the same arrangement as DurationField, and for the same reason: a field\n * that reformats on every keystroke fights the person using it. */\n const [text, setText] = useState(() => display(value))\n const [editing, setEditing] = useState(false)\n\n /* The value this box last saw from outside, adjusted during render rather\n * than in an effect. Comparing against `value` would be wrong in exactly\n * the case that matters: after a commit the parent may still hold the old\n * one for a tick, and the box would clear itself under the reader. */\n const [seen, setSeen] = useState<string | null>(value)\n\n if (value !== seen) {\n setSeen(value)\n if (!editing) setText(display(value))\n }\n\n const commit = () => {\n setEditing(false)\n const parsed = parseTime(text)\n if (parsed === undefined) {\n // Unreadable: put back what the value actually is rather than leaving\n // the box saying something the form does not believe.\n setText(display(value))\n return\n }\n setText(display(parsed))\n if (parsed !== value) onValueChange(parsed)\n }\n\n return (\n <input\n {...props}\n ref={ref}\n type=\"text\"\n inputMode=\"numeric\"\n value={text}\n onFocus={() => setEditing(true)}\n onChange={(event) => setText(event.target.value)}\n onBlur={commit}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n commit()\n }\n }}\n className={cn(fieldClasses, 'h-control tabular-nums', className)}\n />\n )\n}\n"
2124
+ "content": "import { useState, type Ref } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * TimeField - a time of day, typed the way people say it.\n *\n * No donor for this one: neither product of the line had a time field, so\n * this is written from the same shape as DurationField, and for the same\n * reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`,\n * `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.\n *\n * The value is `HH:MM` in twenty-four hours, always, no matter how it was\n * typed or how it is shown. That is what a database column holds and what\n * sorts correctly as a string; whether the reader sees `9:30 PM` or `21:30`\n * is a matter of where they live, and `Intl` answers it.\n *\n * Not `<input type=\"time\">`, and the reason is the same as NumberField's: the\n * browser draws its own control, its own spinner and its own clock popup,\n * none of which a stylesheet reaches - so a form of the product's own fields\n * gets one that is visibly not.\n *\n * Empty is `null`, like the other fields here: no time is not midnight.\n */\n\n/** Minutes since midnight from whatever was typed, or `null` for empty, or\n * `undefined` when it cannot be read as a time.\n *\n * Exported because the parsing is the component - a test that types into the\n * box checks React's state handling, and what has to be right is this. */\nexport function parseTime(text: string): string | null | undefined {\n const input = text.trim().toLowerCase().replace(/\\s+/g, '')\n if (input === '') return null\n\n // `pm` means add twelve hours, `am` means midnight is 12. Stripped first so\n // the rest of the parsing does not have to know about them.\n const meridiem = /(am|pm)$/.exec(input)?.[1]\n const body = meridiem ? input.slice(0, -2) : input\n\n let hours: number\n let minutes: number\n\n const separated = /^(\\d{1,2})[:.](\\d{2})$/.exec(body)\n if (separated) {\n hours = Number(separated[1])\n minutes = Number(separated[2])\n } else if (/^\\d{1,2}$/.test(body)) {\n // A bare number is an hour: `9` is nine o'clock, not nine minutes past\n // midnight - which is what someone typing a time means.\n hours = Number(body)\n minutes = 0\n } else if (/^\\d{3,4}$/.test(body)) {\n // `930` and `0930`, which is how a time gets typed when the colon is a\n // reach on a phone keyboard.\n hours = Number(body.slice(0, body.length - 2))\n minutes = Number(body.slice(-2))\n } else {\n return undefined\n }\n\n if (minutes > 59) return undefined\n\n if (meridiem) {\n if (hours < 1 || hours > 12) return undefined\n if (meridiem === 'pm' && hours !== 12) hours += 12\n if (meridiem === 'am' && hours === 12) hours = 0\n } else if (hours > 23) {\n return undefined\n }\n\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`\n}\n\n/** How a time reads here: `21:30` in most of the world, `9:30 PM` in some of\n * it. The stored value does not change - only what is shown. */\nexport function formatTime(time: string, locale: string): string {\n const [hours, minutes] = time.split(':').map(Number) as [number, number]\n return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(\n new Date(2024, 0, 1, hours, minutes),\n )\n}\n\nexport interface TimeFieldProps {\n /** `HH:MM` in twenty-four hours, or `null` for empty. */\n value: string | null\n onValueChange: (value: string | null) => void\n /** How the time is shown while the field is not being typed into. The\n * application's language unless stated - see `useLocale`. */\n locale?: string\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function TimeField({\n value,\n onValueChange,\n locale,\n className,\n ref,\n ...props\n}: TimeFieldProps) {\n const language = useLocale(locale)\n const display = (time: string | null) => (time === null ? '' : formatTime(time, language))\n\n /* Text while it is being typed, a formatted time the rest of the time -\n * the same arrangement as DurationField, and for the same reason: a field\n * that reformats on every keystroke fights the person using it. */\n const [text, setText] = useState(() => display(value))\n const [editing, setEditing] = useState(false)\n\n /* The value this box last saw from outside, adjusted during render rather\n * than in an effect. Comparing against `value` would be wrong in exactly\n * the case that matters: after a commit the parent may still hold the old\n * one for a tick, and the box would clear itself under the reader. */\n const [seen, setSeen] = useState<string | null>(value)\n\n if (value !== seen) {\n setSeen(value)\n if (!editing) setText(display(value))\n }\n\n const commit = () => {\n setEditing(false)\n const parsed = parseTime(text)\n if (parsed === undefined) {\n // Unreadable: put back what the value actually is rather than leaving\n // the box saying something the form does not believe.\n setText(display(value))\n return\n }\n setText(display(parsed))\n if (parsed !== value) onValueChange(parsed)\n }\n\n return (\n <input\n {...props}\n ref={ref}\n type=\"text\"\n inputMode=\"numeric\"\n value={text}\n onFocus={() => setEditing(true)}\n onChange={(event) => setText(event.target.value)}\n onBlur={commit}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n commit()\n }\n }}\n className={cn(fieldClasses, 'h-control tabular-nums', className)}\n />\n )\n}\n"
1929
2125
  }
1930
2126
  ]
1931
2127
  },
@@ -1936,7 +2132,7 @@
1936
2132
  "description": "What happened, in the order it happened: a release history, an audit trail, the steps a job went through. Four products of the line draw one, and all four drew it the same way - a list with a border on the left and a dot positioned over it by hand.",
1937
2133
  "dependencies": [
1938
2134
  "class-variance-authority",
1939
- "dowel-ui@^0.30.0"
2135
+ "dowel-ui@^0.31.0"
1940
2136
  ],
1941
2137
  "registryDependencies": [],
1942
2138
  "files": [
@@ -1956,7 +2152,7 @@
1956
2152
  "dependencies": [
1957
2153
  "@base-ui/react",
1958
2154
  "class-variance-authority",
1959
- "dowel-ui@^0.30.0"
2155
+ "dowel-ui@^0.31.0"
1960
2156
  ],
1961
2157
  "registryDependencies": [],
1962
2158
  "files": [
@@ -1976,7 +2172,7 @@
1976
2172
  "dependencies": [
1977
2173
  "@base-ui/react",
1978
2174
  "class-variance-authority",
1979
- "dowel-ui@^0.30.0"
2175
+ "dowel-ui@^0.31.0"
1980
2176
  ],
1981
2177
  "registryDependencies": [],
1982
2178
  "files": [
@@ -2011,7 +2207,7 @@
2011
2207
  "description": "Two products had written this independently and arrived at the same construction - a rounded track, segments positioned absolutely by percent, a floor under the segment width so a short one does not vanish - differing only in what a segment meant. One drew the tiers of a rubric with the score standing among them; the other drew a working day as alternating work and breaks. Neither could be built from the other, and each knew something the other did not: the tiers had the marker and the three-state reading of a segment (passed, standing in, still ahead), the day had the minimum width and the difference between an empty track and an unknown one.",
2012
2208
  "dependencies": [
2013
2209
  "class-variance-authority",
2014
- "dowel-ui@^0.30.0"
2210
+ "dowel-ui@^0.31.0"
2015
2211
  ],
2016
2212
  "registryDependencies": [
2017
2213
  "https://lacodda.github.io/dowel/r/track-segments.json"
@@ -2047,7 +2243,7 @@
2047
2243
  "title": "TreeView",
2048
2244
  "description": "The shape products reach for and then get wrong in the same place every time. A tree is not a nest of lists with click handlers - it is one control with a cursor in it, and the difference is the whole component:\n * **One tab stop, not one per node.** A tree of four hundred files with a `tabIndex` on each is four hundred stops between the sidebar and the editor. The container is what the keyboard reaches, and the arrows move a cursor inside it - the arrangement a `RadioGroup` has, for the same reason.",
2049
2245
  "dependencies": [
2050
- "dowel-ui@^0.30.0"
2246
+ "dowel-ui@^0.31.0"
2051
2247
  ],
2052
2248
  "registryDependencies": [
2053
2249
  "https://lacodda.github.io/dowel/r/tree-rows.json"
@@ -2067,7 +2263,7 @@
2067
2263
  "title": "Truncate",
2068
2264
  "description": "Text that does not fit, cut with an ellipsis - and, importantly, still readable in full: the element carries its own text as a `title`, so hovering shows what was cut. Every product wrote the one-line version of this and none of them remembered the title.",
2069
2265
  "dependencies": [
2070
- "dowel-ui@^0.30.0"
2266
+ "dowel-ui@^0.31.0"
2071
2267
  ],
2072
2268
  "registryDependencies": [],
2073
2269
  "files": [
@@ -2085,7 +2281,7 @@
2085
2281
  "title": "VirtualList",
2086
2282
  "description": "The browser is fine with long lists until it is not: a hundred thousand `<div>`s is a layout the machine recomputes on every change, and the page stops responding while it does. What is drawn instead is the window the reader can actually see, held in place by a tall spacer, so the scrollbar still says how much there is.",
2087
2283
  "dependencies": [
2088
- "dowel-ui@^0.30.0"
2284
+ "dowel-ui@^0.31.0"
2089
2285
  ],
2090
2286
  "registryDependencies": [],
2091
2287
  "files": [
@@ -2104,7 +2300,7 @@
2104
2300
  "description": "With `decorations: false` the system draws nothing, so everything it used to do is the page's: dragging the window by its title bar, double-click to maximise, the three buttons, and the edges you grab to resize. Each is small; the reason to take them on at all is that a system title bar over an application title bar costs a strip of every laptop screen for nothing.",
2105
2301
  "dependencies": [
2106
2302
  "@tauri-apps/api",
2107
- "dowel-ui@^0.30.0"
2303
+ "dowel-ui@^0.31.0"
2108
2304
  ],
2109
2305
  "registryDependencies": [],
2110
2306
  "files": [
@@ -2112,7 +2308,28 @@
2112
2308
  "path": "ui/window-frame.tsx",
2113
2309
  "target": "@ui/window-frame.tsx",
2114
2310
  "type": "registry:ui",
2115
- "content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * Four exports, and they are used together: `WindowButtons` in the bar,\n * `useTitleBarGestures()` spread on the bar, `ResizeEdges` once at the root,\n * and `useMaximized()` for anything else that changes shape with the window.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-window-button cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\n/* The width of a strip and of a corner, as the theme states them. Read off\n * the tokens rather than written here: a window's chrome is shared with the\n * products that draw the rest of their own frame, and two numbers for one\n * edge is how the title bar ended up 40px in one product and 2.4rem in the\n * next. */\nconst EDGE = 'var(--spacing-resize-edge)'\nconst CORNER = 'var(--spacing-resize-corner)'\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n"
2311
+ "content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * `TitleBar` is the bar itself, assembled: the product's mark, whatever the\n * window shows at the top (its open documents as `Tabs variant=\"bar\"`, or a\n * trail), a stretch that exists only to be grabbed, the product's own actions,\n * and the three buttons. `ResizeEdges` goes once at the root beside it. The\n * parts are exported too - `WindowButtons`, `useTitleBarGestures()` spread on\n * a bar of your own, and `useMaximized()` for anything else that changes shape\n * with the window - for a bar the assembled one does not fit.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them.\n *\n * Close asks rather than closes: Tauri's `close()` emits `closeRequested`\n * before anything happens, so a product's unsaved-work guard listening for\n * that request sees this button exactly as it sees the system's own close.\n * scheda once took an `onClose` of its own to get that; it was never needed,\n * and a second way to close is a second place for the guard to be missed. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-window-button cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\n/* The width of a strip and of a corner, as the theme states them. Read off\n * the tokens rather than written here: a window's chrome is shared with the\n * products that draw the rest of their own frame, and two numbers for one\n * edge is how the title bar ended up 40px in one product and 2.4rem in the\n * next. */\nconst EDGE = 'var(--spacing-resize-edge)'\nconst CORNER = 'var(--spacing-resize-corner)'\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n\nexport interface TitleBarProps {\n /** The window buttons' names; see `WindowButtons`. */\n labels: WindowButtonsProps['labels']\n /** The product's mark, at the left edge, where the system put the icon. */\n mark?: ReactNode\n /** What the window shows at the top: its open documents as\n * `<Tabs><TabsList variant=\"bar\">`, a trail, or a title. */\n children?: ReactNode\n /** The product's own controls, between the handle and the window buttons. */\n actions?: ReactNode\n className?: string\n}\n\n/** A frameless window's title bar, assembled.\n *\n * Its height is `--spacing-titlebar`, and everything in it that is not a\n * control is a handle. The stretch between the content and the actions is\n * there for that alone: a window with twenty documents open would otherwise\n * have no bar left to drag by, so it never shrinks below a minimum, and the\n * content scrolls instead. */\nexport function TitleBar({ labels, mark, children, actions, className }: TitleBarProps) {\n const gestures = useTitleBarGestures()\n\n return (\n <header\n className={cn('flex h-titlebar shrink-0 items-stretch border-b border-line bg-raise select-none', className)}\n {...gestures}\n >\n {mark && <span className=\"flex shrink-0 items-center pr-2 pl-3\">{mark}</span>}\n <div className=\"flex min-w-0 items-stretch\">{children}</div>\n <div aria-hidden data-titlebar-handle className=\"min-w-4 flex-1\" />\n {actions && <div className=\"flex shrink-0 items-center gap-1 px-1\">{actions}</div>}\n <WindowButtons labels={labels} />\n </header>\n )\n}\n"
2312
+ }
2313
+ ]
2314
+ },
2315
+ {
2316
+ "name": "wizard",
2317
+ "type": "registry:ui",
2318
+ "title": "Wizard",
2319
+ "description": "A Stepper with the current step's content under it and the Back / Next / Finish row that walks through the steps - checking each one before it lets the user past. The Stepper alone shows progress; this is the form that makes it, and a product that only needs the picture should not install the form.",
2320
+ "dependencies": [
2321
+ "dowel-ui@^0.31.0"
2322
+ ],
2323
+ "registryDependencies": [
2324
+ "https://lacodda.github.io/dowel/r/button.json",
2325
+ "https://lacodda.github.io/dowel/r/stepper.json"
2326
+ ],
2327
+ "files": [
2328
+ {
2329
+ "path": "ui/wizard.tsx",
2330
+ "target": "@ui/wizard.tsx",
2331
+ "type": "registry:ui",
2332
+ "content": "import {\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n type FormEvent,\n type HTMLAttributes,\n type ReactNode,\n} from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Stepper, type StepStateLabels, type StepperStep } from './stepper'\n\n/*\n * Wizard.\n *\n * A Stepper with the current step's content under it and the Back / Next /\n * Finish row that walks through the steps - checking each one before it lets\n * the user past. The Stepper alone shows progress; this is the form that\n * makes it, and a product that only needs the picture should not install the\n * form.\n *\n * Every way forward runs the same checks: Next, Enter in a field, and a click\n * on a completed step ahead in the stepper. The browser's own constraints on\n * the step's fields come first, then `canAdvance`, then the async `onNext`;\n * `false` or a throw keeps the user where they are and marks the step failed.\n * A wizard whose Enter key or stepper skipped the checks would have checks\n * that only the mouse on the Next button ever met.\n *\n * It is both controlled and uncontrolled, like every input in the set:\n * `defaultStep` for the common case where nothing outside cares which step is\n * showing, `step` + `onStepChange` for the case where something does - a step\n * in the URL, so the browser's Back button walks the wizard, or a product\n * that restores a half-finished setup. Either way the wizard runs the step's\n * checks before it asks to move forward; a controlled product decides whether\n * to move, never whether to validate.\n *\n * Its steps stay mounted once visited and are hidden rather than unmounted,\n * so going Back finds the fields as they were left - typed text, a picked\n * folder, a toggled switch - whether or not the product lifted that state\n * out. Losing a page of input to a Back button is the complaint every wizard\n * collects first, and the cost of keeping a handful of hidden panels in the\n * document is small against it.\n *\n * When the step changes, focus moves to the new step's heading, so a keyboard\n * or screen-reader user lands in the new content rather than on a Next button\n * at the bottom of a page they have not heard. All the words - the buttons,\n * the stepper's - are the product's, and required.\n */\n\n/** What a step's check may return. `false` blocks; so does throwing. Anything\n * else lets the wizard go on. */\ntype CheckResult = boolean | void\n\nexport interface WizardStep extends Omit<StepperStep, 'status'> {\n /** The step itself - its fields, its choices. Stays mounted once visited. */\n content: ReactNode\n /** A synchronous check, run first. `false` keeps the user on the step. */\n canAdvance?: () => boolean\n /**\n * Run when the user asks to leave the step forward, after `canAdvance`. May\n * be async - a name checked against a server, a folder checked for write\n * access. Returning `false` or throwing keeps the user on the step; the\n * controls are disabled while it runs, so a second press does not run it\n * twice.\n */\n onNext?: () => CheckResult | Promise<CheckResult>\n /** Mark the step as failed from outside - a server rejected what it holds. */\n error?: boolean\n}\n\nexport interface WizardProps extends Omit<HTMLAttributes<HTMLFormElement>, 'children' | 'onSubmit' | 'onError'> {\n steps: readonly WizardStep[]\n /** The step shown, for a controlled wizard. */\n step?: string\n /** The step shown first, for an uncontrolled one. The first step if omitted. */\n defaultStep?: string\n /** Called with the step to move to - after its checks have passed, when\n * moving forward. A controlled wizard moves when the product sets `step`. */\n onStepChange?: (id: string) => void\n /** Called when Finish is pressed on the last step and its checks pass. */\n onFinish: () => void | Promise<void>\n /** Called when a step's `onNext` throws. The step is blocked and marked\n * failed either way; without this the error is rethrown so it is not lost. */\n onError?: (error: unknown, stepId: string) => void\n /** The stepper's name for a reader. */\n stepperLabel: string\n stateLabels: StepStateLabels\n summary?: (position: number, total: number) => ReactNode\n /** The buttons' words. Required: the component has none of its own. */\n backLabel: ReactNode\n nextLabel: ReactNode\n finishLabel: ReactNode\n /** Stepper above the content, or beside it. */\n orientation?: 'horizontal' | 'vertical'\n}\n\nexport function Wizard({\n steps,\n step: controlledStep,\n defaultStep,\n onStepChange,\n onFinish,\n onError,\n stepperLabel,\n stateLabels,\n summary,\n backLabel,\n nextLabel,\n finishLabel,\n orientation = 'horizontal',\n className,\n ...props\n}: WizardProps) {\n const [ownStep, setOwnStep] = useState(defaultStep ?? steps[0]?.id ?? '')\n const current = controlledStep ?? ownStep\n const currentIndex = Math.max(\n 0,\n steps.findIndex((step) => step.id === current),\n )\n const currentStep = steps[currentIndex]\n\n // Steps that were ever shown stay mounted, so Back finds them as they were.\n const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set([current]))\n // Steps whose checks passed when they were last left forward. A step ahead\n // of the current one is drawn as done only if it is in here.\n const [passed, setPassed] = useState<ReadonlySet<string>>(() => new Set())\n const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set())\n const [pending, setPending] = useState(false)\n // The guard against a second press while a check runs reads this rather\n // than `pending`: two presses inside one frame both see the state from\n // before either of them.\n const running = useRef(false)\n\n const panels = useRef(new Map<string, HTMLElement>())\n const heading = useRef<HTMLHeadingElement>(null)\n const baseId = useId()\n\n // `visited` is derived from `current` as it changes rather than set where\n // the move is made, because a controlled wizard moves when the product\n // says so and the move is not made here at all.\n if (!visited.has(current)) setVisited(new Set(visited).add(current))\n\n // The step changed: put the keyboard in its heading. Without this focus\n // stays on the Next button - now at the bottom of a different page - and a\n // screen reader says nothing about the new step at all. Not on the first\n // render: a wizard that steals focus when a screen opens moves the reader\n // away from wherever they were.\n const first = useRef(true)\n useEffect(() => {\n if (first.current) {\n first.current = false\n return\n }\n heading.current?.focus()\n }, [current])\n\n const goTo = useCallback(\n (id: string) => {\n if (controlledStep === undefined) setOwnStep(id)\n onStepChange?.(id)\n },\n [controlledStep, onStepChange],\n )\n\n const mark = (id: string, ok: boolean) => {\n setFailed((prev) => {\n if (prev.has(id) === !ok) return prev\n const next = new Set(prev)\n if (ok) next.delete(id)\n else next.add(id)\n return next\n })\n setPassed((prev) => {\n if (prev.has(id) === ok) return prev\n const next = new Set(prev)\n if (ok) next.add(id)\n else next.delete(id)\n return next\n })\n }\n\n /** Run one step's checks, in order: the browser's own constraints on its\n * fields (`required`, `pattern`), then `canAdvance`, then `onNext`. */\n const check = async (target: WizardStep): Promise<boolean> => {\n const panel = panels.current.get(target.id)\n if (panel) {\n // The form is `noValidate`, because the browser would otherwise check\n // every field in it - including the hidden ones of steps already left -\n // and refuse to submit over a field nobody can see. So the check is\n // made here, one step's fields at a time.\n const fields = panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(\n 'input, select, textarea',\n )\n for (const field of fields) {\n if (!field.checkValidity()) {\n if (target.id === current) field.reportValidity()\n return false\n }\n }\n }\n if (target.canAdvance && !target.canAdvance()) return false\n if (target.onNext) {\n try {\n const result = await target.onNext()\n if (result === false) return false\n } catch (error) {\n mark(target.id, false)\n if (onError) onError(error, target.id)\n else throw error\n return false\n }\n }\n return true\n }\n\n /** Move forward to `targetIndex`, checking every step on the way. A step\n * that fails stops the walk there and is the one shown, so the user lands\n * on the thing that needs fixing rather than past it. */\n const advance = async (targetIndex: number, finish: boolean) => {\n if (running.current) return\n running.current = true\n setPending(true)\n try {\n for (let index = currentIndex; index < targetIndex || (finish && index === currentIndex); index += 1) {\n const target = steps[index]!\n const ok = await check(target)\n mark(target.id, ok)\n if (!ok) {\n if (index !== currentIndex) goTo(target.id)\n return\n }\n if (finish) {\n await onFinish()\n return\n }\n }\n const target = steps[targetIndex]\n if (target) goTo(target.id)\n } finally {\n running.current = false\n setPending(false)\n }\n }\n\n const isLast = currentIndex === steps.length - 1\n\n const onSubmit = (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n // Enter in a field is Next, through the same checks the button runs - a\n // wizard is a form and people press Enter in forms. On the last step it\n // does nothing: finishing sets something up, and that should take a\n // press of the button that says so, not a key pressed to leave a field.\n if (isLast) return\n void advance(currentIndex + 1, false)\n }\n\n const stepperSteps: StepperStep[] = steps.map((step, index) => ({\n id: step.id,\n label: step.label,\n description: step.description,\n status:\n step.error || failed.has(step.id)\n ? 'error'\n : index > currentIndex && passed.has(step.id)\n ? 'done'\n : undefined,\n }))\n\n const onStepSelect = (id: string) => {\n const index = steps.findIndex((step) => step.id === id)\n if (index < 0 || index === currentIndex || pending) return\n // Back is free; forward walks through every check in between.\n if (index < currentIndex) goTo(id)\n else void advance(index, false)\n }\n\n const vertical = orientation === 'vertical'\n\n return (\n <form\n noValidate\n aria-busy={pending || undefined}\n onSubmit={onSubmit}\n className={cn('flex gap-6', vertical ? 'flex-row' : 'flex-col', className)}\n {...props}\n >\n <Stepper\n label={stepperLabel}\n steps={stepperSteps}\n current={current}\n stateLabels={stateLabels}\n summary={summary}\n orientation={orientation}\n onStepSelect={onStepSelect}\n className={vertical ? 'w-56 shrink-0' : undefined}\n />\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-4\">\n {steps.map((step) => {\n if (!visited.has(step.id)) return null\n const shown = step.id === currentStep?.id\n const headingId = `${baseId}-${step.id}`\n return (\n <section\n key={step.id}\n hidden={!shown}\n aria-labelledby={headingId}\n ref={(node) => {\n if (node) panels.current.set(step.id, node)\n else panels.current.delete(step.id)\n }}\n className=\"flex flex-col gap-4\"\n >\n <div>\n <h2\n id={headingId}\n ref={shown ? heading : undefined}\n // Focusable by script only: it is where the wizard puts the\n // reader on a step change, not a stop on the Tab order.\n tabIndex={-1}\n className=\"rounded-xs text-base font-semibold text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\"\n >\n {step.label}\n </h2>\n {step.description !== undefined && (\n <p className=\"mt-1 text-sm text-dim\">{step.description}</p>\n )}\n </div>\n {step.content}\n </section>\n )\n })}\n\n <div className=\"flex items-center justify-between gap-2 border-t border-line pt-4\">\n {currentIndex > 0 ? (\n <Button\n variant=\"ghost\"\n disabled={pending}\n onClick={() => goTo(steps[currentIndex - 1]!.id)}\n >\n {backLabel}\n </Button>\n ) : (\n // Holds Next to the right on the first step, where there is no\n // Back: a disabled Back would be a control that does nothing.\n <span />\n )}\n {isLast ? (\n <Button variant=\"primary\" disabled={pending} onClick={() => void advance(currentIndex, true)}>\n {finishLabel}\n </Button>\n ) : (\n // `submit`, so Enter in a field is this button - and runs the\n // same checks.\n <Button type=\"submit\" variant=\"primary\" disabled={pending}>\n {nextLabel}\n </Button>\n )}\n </div>\n </div>\n </form>\n )\n}\n"
2116
2333
  }
2117
2334
  ]
2118
2335
  },