dowel-ui 0.30.0 → 0.32.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 (107) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +55 -19
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/eslint/index.d.ts +1 -0
  6. package/dist/eslint/index.d.ts.map +1 -1
  7. package/dist/eslint/index.js +13 -4
  8. package/dist/eslint/index.js.map +1 -1
  9. package/dist/eslint/no-implicit-locale.d.ts +3 -0
  10. package/dist/eslint/no-implicit-locale.d.ts.map +1 -0
  11. package/dist/eslint/no-implicit-locale.js +83 -0
  12. package/dist/eslint/no-implicit-locale.js.map +1 -0
  13. package/dist/index.d.ts +6 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +5 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/locale.d.ts +16 -0
  18. package/dist/locale.d.ts.map +1 -0
  19. package/dist/locale.js +55 -0
  20. package/dist/locale.js.map +1 -0
  21. package/dist/marks/atlas-L.svg +3 -0
  22. package/dist/marks/atlas-M.svg +1 -0
  23. package/dist/marks/atlas-S.svg +1 -0
  24. package/dist/marks/austeris-L.svg +6 -0
  25. package/dist/marks/austeris-M.svg +1 -0
  26. package/dist/marks/austeris-S.svg +1 -0
  27. package/dist/marks/dowel-L.svg +5 -0
  28. package/dist/marks/dowel-M.svg +1 -0
  29. package/dist/marks/dowel-S.svg +1 -0
  30. package/dist/marks/efema-L.svg +8 -0
  31. package/dist/marks/efema-M.svg +1 -0
  32. package/dist/marks/efema-S.svg +1 -0
  33. package/dist/marks/furca-L.svg +7 -0
  34. package/dist/marks/furca-M.svg +1 -0
  35. package/dist/marks/furca-S.svg +1 -0
  36. package/dist/marks/hilvan-L.svg +3 -0
  37. package/dist/marks/hilvan-M.svg +1 -0
  38. package/dist/marks/hilvan-S.svg +1 -0
  39. package/dist/marks/kasl-L.svg +4 -0
  40. package/dist/marks/kasl-M.svg +1 -0
  41. package/dist/marks/kasl-S.svg +1 -0
  42. package/dist/marks/kasl-server-L.svg +4 -0
  43. package/dist/marks/kasl-server-M.svg +1 -0
  44. package/dist/marks/kasl-server-S.svg +1 -0
  45. package/dist/marks/kilna-L.svg +3 -0
  46. package/dist/marks/kilna-M.svg +1 -0
  47. package/dist/marks/kilna-S.svg +1 -0
  48. package/dist/marks/lacodda-L.svg +1 -0
  49. package/dist/marks/lacodda-M.svg +1 -0
  50. package/dist/marks/lacodda-S.svg +1 -0
  51. package/dist/marks/lyrid-L.svg +8 -0
  52. package/dist/marks/lyrid-M.svg +1 -0
  53. package/dist/marks/lyrid-S.svg +1 -0
  54. package/dist/marks/lyrn-L.svg +3 -0
  55. package/dist/marks/lyrn-M.svg +1 -0
  56. package/dist/marks/lyrn-S.svg +1 -0
  57. package/dist/marks/midda-L.svg +4 -0
  58. package/dist/marks/midda-M.svg +1 -0
  59. package/dist/marks/midda-S.svg +1 -0
  60. package/dist/marks/nitid-L.svg +4 -0
  61. package/dist/marks/nitid-M.svg +1 -0
  62. package/dist/marks/nitid-S.svg +1 -0
  63. package/dist/marks/nooma-L.svg +12 -0
  64. package/dist/marks/nooma-M.svg +1 -0
  65. package/dist/marks/nooma-S.svg +1 -0
  66. package/dist/marks/rhapsod-L.svg +4 -0
  67. package/dist/marks/rhapsod-M.svg +1 -0
  68. package/dist/marks/rhapsod-S.svg +1 -0
  69. package/dist/marks/rigger-L.svg +8 -0
  70. package/dist/marks/rigger-M.svg +1 -0
  71. package/dist/marks/rigger-S.svg +1 -0
  72. package/dist/marks/scheda-L.svg +5 -0
  73. package/dist/marks/scheda-M.svg +1 -0
  74. package/dist/marks/scheda-S.svg +1 -0
  75. package/dist/marks/sefy-L.svg +4 -0
  76. package/dist/marks/sefy-M.svg +1 -0
  77. package/dist/marks/sefy-S.svg +1 -0
  78. package/dist/marks/turnout-L.svg +4 -0
  79. package/dist/marks/turnout-M.svg +1 -0
  80. package/dist/marks/turnout-S.svg +1 -0
  81. package/dist/marks.d.ts +12 -0
  82. package/dist/marks.d.ts.map +1 -0
  83. package/dist/marks.js +121 -0
  84. package/dist/marks.js.map +1 -0
  85. package/dist/palettes/atlas.json +1 -1
  86. package/dist/palettes/austeris.json +1 -1
  87. package/dist/palettes/dowel.json +1 -1
  88. package/dist/palettes/efema.json +1 -1
  89. package/dist/palettes/furca.json +1 -1
  90. package/dist/palettes/hilvan.json +1 -1
  91. package/dist/palettes/kasl-server.json +1 -1
  92. package/dist/palettes/kasl.json +1 -1
  93. package/dist/palettes/kilna.json +1 -1
  94. package/dist/palettes/lyrid.json +1 -1
  95. package/dist/palettes/lyrn.json +1 -1
  96. package/dist/palettes/midda.json +1 -1
  97. package/dist/palettes/nitid.json +1 -1
  98. package/dist/palettes/nooma.json +1 -1
  99. package/dist/palettes/rhapsod.json +1 -1
  100. package/dist/palettes/rigger.json +1 -1
  101. package/dist/palettes/scheda.json +1 -1
  102. package/dist/palettes/sefy.json +1 -1
  103. package/dist/palettes/turnout.json +1 -1
  104. package/dist/registry.json +378 -121
  105. package/dist/theme.css +39 -0
  106. package/dist/tokens.json +7 -0
  107. package/package.json +8 -3
@@ -14,7 +14,7 @@
14
14
  "path": "dowel/theme.css",
15
15
  "target": "~/dowel/theme.css",
16
16
  "type": "registry:file",
17
- "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /*\n * A step between the steps.\n *\n * The spacing scale runs in fours - 2, 4, 6, 8 - and four different\n * primitives independently reached past it for three pixels: the gap\n * between segments of a rating and of an axis, the gap between cells of a\n * heatmap, and how far a tooltip's arrow tucks under its popup. Four hands\n * arriving at the same number is not four accidents; it is a step the scale\n * was missing, and each of them wrote `[3px]` because there was nothing to\n * name.\n *\n * It exists for hairline gaps between things that are themselves small -\n * where two pixels reads as touching and four as separate objects. Nothing\n * larger belongs here: this is the bottom of the scale, not a licence to\n * measure by eye.\n */\n --spacing-hair: 3px;\n\n /*\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n\n /*\n * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n\n /*\n * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
17
+ "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /*\n * A step between the steps.\n *\n * The spacing scale runs in fours - 2, 4, 6, 8 - and four different\n * primitives independently reached past it for three pixels: the gap\n * between segments of a rating and of an axis, the gap between cells of a\n * heatmap, and how far a tooltip's arrow tucks under its popup. Four hands\n * arriving at the same number is not four accidents; it is a step the scale\n * was missing, and each of them wrote `[3px]` because there was nothing to\n * name.\n *\n * It exists for hairline gaps between things that are themselves small -\n * where two pixels reads as touching and four as separate objects. Nothing\n * larger belongs here: this is the bottom of the scale, not a licence to\n * measure by eye.\n */\n --spacing-hair: 3px;\n\n /*\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n\n /*\n * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n\n /*\n * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
18
18
  }
19
19
  ],
20
20
  "docs": "Import the theme, then your product accent:\n\n @import './dowel/theme.css';\n @import './dowel/accents/kilna.css';\n\nOutside the line, set the colour directly instead:\n\n :root { --accent-base: #2f7d6b; }"
@@ -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.32.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.32.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.32.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.32.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.32.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.32.0"
390
431
  ],
391
432
  "registryDependencies": [],
392
433
  "files": [
@@ -394,7 +435,7 @@
394
435
  "path": "ui/alert.tsx",
395
436
  "target": "@ui/alert.tsx",
396
437
  "type": "registry:ui",
397
- "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Alert.\n *\n * A message that stays on the screen, in the flow of the page, about the thing\n * next to it: this field could not be saved, this profile has no axes yet,\n * this export is out of date.\n *\n * Not a Toast, and the difference is worth stating because products keep\n * reaching for the wrong one. A toast is for something that just happened and\n * then goes away; an alert is for a condition that is still true and will\n * still be true after a reload. If dismissing it would be a lie, it is an\n * alert.\n *\n * Not a Banner either: a banner spans the application and speaks about the\n * whole of it. This speaks about what it sits beside.\n *\n * `role` is the caller's decision, and the default is deliberately quiet. An\n * alert that appears in response to something the reader just did should be\n * `role=\"alert\"` so it is announced; one that is simply part of the page\n * should not be, or a screen reader interrupts itself reading the furniture.\n */\n\nexport const alertVariants = cva(\n ['flex gap-2.5 rounded-md border p-3 text-sm', '[&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:shrink-0'],\n {\n variants: {\n tone: {\n neutral: 'border-line bg-soft text-dim',\n good: 'border-good/40 bg-good-soft text-good',\n warn: 'border-warn/40 bg-warn-soft text-warn',\n bad: 'border-bad/40 bg-bad-soft text-bad',\n info: 'border-info/40 bg-info-soft text-info',\n },\n },\n defaultVariants: { tone: 'neutral' },\n },\n)\n\nexport interface AlertProps\n // `title` on a div is the browser's tooltip and is a string; here it is the\n // heading, and can be anything a product wants to draw.\n extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n VariantProps<typeof alertVariants> {\n /** Drawn before the text. The product's own, because an icon that means\n * \"warning\" here should be the one it means everywhere else in the product. */\n icon?: ReactNode\n /** The heading. Optional: a one-line alert does not need one. */\n title?: ReactNode\n /** Anything that goes at the end - a link to the thing that fixes it, a\n * dismiss button the product owns. */\n action?: ReactNode\n}\n\nexport function Alert({ tone, icon, title, action, className, children, ...props }: AlertProps) {\n return (\n <div className={cn(alertVariants({ tone }), className)} {...props}>\n {icon}\n <div className=\"min-w-0 flex-1\">\n {title !== undefined && <div className=\"font-semibold\">{title}</div>}\n {children !== undefined && (\n <div className={cn('text-xs', title !== undefined && 'mt-0.5')}>{children}</div>\n )}\n </div>\n {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n </div>\n )\n}\n"
438
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Alert.\n *\n * A message that stays on the screen, in the flow of the page, about the thing\n * next to it: this field could not be saved, this profile has no axes yet,\n * this export is out of date.\n *\n * Not a Toast, and the difference is worth stating because products keep\n * reaching for the wrong one. A toast is for something that just happened and\n * then goes away; an alert is for a condition that is still true and will\n * still be true after a reload. If dismissing it would be a lie, it is an\n * alert.\n *\n * Not a Banner either: a banner spans the application and speaks about the\n * whole of it. This speaks about what it sits beside.\n *\n * `role` is the caller's decision, and the default is deliberately quiet. An\n * alert that appears in response to something the reader just did should be\n * `role=\"alert\"` so it is announced; one that is simply part of the page\n * should not be, or a screen reader interrupts itself reading the furniture.\n */\n\nexport const alertVariants = cva(\n ['flex gap-2.5 rounded-md border p-3 text-sm', '[&_svg]:mt-0.5 [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0'],\n {\n variants: {\n tone: {\n neutral: 'border-line bg-soft text-dim',\n good: 'border-good/40 bg-good-soft text-good',\n warn: 'border-warn/40 bg-warn-soft text-warn',\n bad: 'border-bad/40 bg-bad-soft text-bad',\n info: 'border-info/40 bg-info-soft text-info',\n },\n },\n defaultVariants: { tone: 'neutral' },\n },\n)\n\nexport interface AlertProps\n // `title` on a div is the browser's tooltip and is a string; here it is the\n // heading, and can be anything a product wants to draw.\n extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n VariantProps<typeof alertVariants> {\n /** Drawn before the text. The product's own, because an icon that means\n * \"warning\" here should be the one it means everywhere else in the product. */\n icon?: ReactNode\n /** The heading. Optional: a one-line alert does not need one. */\n title?: ReactNode\n /** Anything that goes at the end - a link to the thing that fixes it, a\n * dismiss button the product owns. */\n action?: ReactNode\n}\n\nexport function Alert({ tone, icon, title, action, className, children, ...props }: AlertProps) {\n return (\n <div className={cn(alertVariants({ tone }), className)} {...props}>\n {icon}\n <div className=\"min-w-0 flex-1\">\n {title !== undefined && <div className=\"font-semibold\">{title}</div>}\n {children !== undefined && (\n <div className={cn('text-xs', title !== undefined && 'mt-0.5')}>{children}</div>\n )}\n </div>\n {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n </div>\n )\n}\n"
398
439
  }
399
440
  ]
400
441
  },
@@ -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.32.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.32.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.32.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.32.0"
466
507
  ],
467
508
  "registryDependencies": [],
468
509
  "files": [
@@ -470,7 +511,7 @@
470
511
  "path": "ui/banner.tsx",
471
512
  "target": "@ui/banner.tsx",
472
513
  "type": "registry:ui",
473
- "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Banner.\n *\n * A strip across the top of the application, about the application: you are\n * offline, this build is a preview, your licence expires on Friday, a new\n * version is ready to install.\n *\n * The difference from Alert is what it speaks about rather than how it looks.\n * An alert belongs to what it sits beside; a banner belongs to the whole\n * screen and is true no matter which one you are on. That is also why it is\n * not dismissed by the component: whether \"you are offline\" can be dismissed\n * is the product's judgement, not the banner's, so the close button is passed\n * in like any other action.\n *\n * It is a `<div role=\"status\">` by default rather than `role=\"alert\"`: a\n * banner is usually already there when the screen loads, and an alert region\n * that fires on load interrupts whatever a screen reader was saying about the\n * page.\n */\n\nexport const bannerVariants = cva(\n [\n 'flex w-full items-center gap-3 border-b px-4 py-2 text-sm',\n '[&_svg]:size-4 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n neutral: 'border-line bg-raise text-dim',\n accent: 'border-accent/30 bg-accent-soft text-accent',\n good: 'border-good/30 bg-good-soft text-good',\n warn: 'border-warn/30 bg-warn-soft text-warn',\n bad: 'border-bad/30 bg-bad-soft text-bad',\n info: 'border-info/30 bg-info-soft text-info',\n },\n /** Pinned to the top of the viewport, above the application's own\n * chrome. For the ones that must not scroll away - offline, expired. */\n sticky: {\n true: 'sticky top-0 [z-index:var(--z-sticky)]',\n false: '',\n },\n },\n defaultVariants: { tone: 'neutral', sticky: false },\n },\n)\n\nexport interface BannerProps\n extends HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof bannerVariants> {\n /** Drawn first. The product's own icon, for the same reason as Alert's. */\n icon?: ReactNode\n /** Anything at the end: a button that fixes it, a link, a dismiss the\n * product decides is allowed. */\n action?: ReactNode\n}\n\nexport function Banner({\n tone,\n sticky,\n icon,\n action,\n role = 'status',\n className,\n children,\n ...props\n}: BannerProps) {\n return (\n <div className={cn(bannerVariants({ tone, sticky }), className)} role={role} {...props}>\n {icon}\n <div className=\"min-w-0 flex-1\">{children}</div>\n {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n </div>\n )\n}\n"
514
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Banner.\n *\n * A strip across the top of the application, about the application: you are\n * offline, this build is a preview, your licence expires on Friday, a new\n * version is ready to install.\n *\n * The difference from Alert is what it speaks about rather than how it looks.\n * An alert belongs to what it sits beside; a banner belongs to the whole\n * screen and is true no matter which one you are on. That is also why it is\n * not dismissed by the component: whether \"you are offline\" can be dismissed\n * is the product's judgement, not the banner's, so the close button is passed\n * in like any other action.\n *\n * It is a `<div role=\"status\">` by default rather than `role=\"alert\"`: a\n * banner is usually already there when the screen loads, and an alert region\n * that fires on load interrupts whatever a screen reader was saying about the\n * page.\n */\n\nexport const bannerVariants = cva(\n [\n 'flex w-full items-center gap-3 border-b px-4 py-2 text-sm',\n '[&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n neutral: 'border-line bg-raise text-dim',\n accent: 'border-accent/30 bg-accent-soft text-accent',\n good: 'border-good/30 bg-good-soft text-good',\n warn: 'border-warn/30 bg-warn-soft text-warn',\n bad: 'border-bad/30 bg-bad-soft text-bad',\n info: 'border-info/30 bg-info-soft text-info',\n },\n /** Pinned to the top of the viewport, above the application's own\n * chrome. For the ones that must not scroll away - offline, expired. */\n sticky: {\n true: 'sticky top-0 [z-index:var(--z-sticky)]',\n false: '',\n },\n },\n defaultVariants: { tone: 'neutral', sticky: false },\n },\n)\n\nexport interface BannerProps\n extends HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof bannerVariants> {\n /** Drawn first. The product's own icon, for the same reason as Alert's. */\n icon?: ReactNode\n /** Anything at the end: a button that fixes it, a link, a dismiss the\n * product decides is allowed. */\n action?: ReactNode\n}\n\nexport function Banner({\n tone,\n sticky,\n icon,\n action,\n role = 'status',\n className,\n children,\n ...props\n}: BannerProps) {\n return (\n <div className={cn(bannerVariants({ tone, sticky }), className)} role={role} {...props}>\n {icon}\n <div className=\"min-w-0 flex-1\">{children}</div>\n {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n </div>\n )\n}\n"
474
515
  }
475
516
  ]
476
517
  },
@@ -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.32.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.32.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.32.0"
524
565
  ],
525
566
  "registryDependencies": [],
526
567
  "files": [
@@ -528,7 +569,7 @@
528
569
  "path": "ui/button.tsx",
529
570
  "target": "@ui/button.tsx",
530
571
  "type": "registry:ui",
531
- "content": "import type { ButtonHTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { useRender } from '@base-ui/react/use-render'\n// `cn` comes from the package rather than being copied in beside the\n// component (ADR 0002): a helper every primitive shares should update\n// centrally, and a project installing a component already has the package for\n// the theme. shadcn's own components import it from `@/lib/utils`; that is a\n// per-project alias, and a copied file cannot know what it points at.\nimport { cn } from 'dowel-ui'\n\n/*\n * Button.\n *\n * Five variants, because that is what the line's products actually reach for:\n * one primary action per screen, a quiet default, a soft accent for something\n * selected, a destructive one, and an icon-only.\n *\n * Every colour and every size is a token. There are no `dark:` utilities and\n * no raw values - the theme swaps underneath, so the same class list is\n * correct in both themes and in every product's accent.\n */\nexport const buttonVariants = cva(\n [\n 'inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap',\n 'font-medium transition-colors',\n // Disabled is a state, not a colour: the button keeps its own hue and\n // loses contact instead, which reads the same whatever the accent is.\n 'disabled:pointer-events-none disabled:opacity-50',\n '[&_svg]:shrink-0',\n ],\n {\n variants: {\n variant: {\n primary: 'rounded-md bg-accent font-semibold text-on-accent hover:bg-accent-2',\n ghost: 'rounded-md border border-line text-dim hover:border-line-2 hover:text-text',\n soft: 'rounded-md bg-accent-soft text-accent hover:bg-accent-soft/60',\n danger: 'rounded-md text-bad hover:bg-bad-soft',\n icon: 'rounded-md text-dim hover:bg-soft hover:text-text',\n },\n size: {\n sm: 'h-7 px-2.5 text-xs',\n md: 'h-9 px-3.5 text-sm',\n 'icon-sm': 'size-7 [&_svg]:size-3.5',\n 'icon-md': 'size-8 [&_svg]:size-4',\n },\n },\n defaultVariants: { variant: 'ghost', size: 'md' },\n },\n)\n\nexport interface ButtonProps\n extends ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n /**\n * Render something else with the button's clothes on - a link, most often.\n *\n * Takes the element itself rather than a boolean: `render={<a href=\"…\" />}`.\n * A function is also accepted, for the rare case that needs the props\n * before deciding what to build with them.\n */\n render?: useRender.RenderProp\n}\n\nexport function Button({ variant, size, render, className, type, ...props }: ButtonProps) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n // A `<button>` inside a form submits it unless told otherwise, which\n // surprises everyone once. When rendering as something else the\n // attribute is meaningless and would land on an `<a>`, so it is only\n // set for the element that has it - `render` is what says which.\n ...(render === undefined && type === undefined ? { type: 'button' } : { type }),\n className: cn(buttonVariants({ variant, size }), className),\n ...props,\n },\n })\n}\n"
572
+ "content": "import type { ButtonHTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { useRender } from '@base-ui/react/use-render'\n// `cn` comes from the package rather than being copied in beside the\n// component (ADR 0002): a helper every primitive shares should update\n// centrally, and a project installing a component already has the package for\n// the theme. shadcn's own components import it from `@/lib/utils`; that is a\n// per-project alias, and a copied file cannot know what it points at.\nimport { cn } from 'dowel-ui'\n\n/*\n * Button.\n *\n * Five variants, because that is what the line's products actually reach for:\n * one primary action per screen, a quiet default, a soft accent for something\n * selected, a destructive one, and an icon-only.\n *\n * Every colour and every size is a token. There are no `dark:` utilities and\n * no raw values - the theme swaps underneath, so the same class list is\n * correct in both themes and in every product's accent.\n *\n * **Height is a control row, not a number.** `md` and `sm` stand on\n * `h-control` and `h-control-sm`, the rows every field of the set stands on,\n * so a button beside an input of the same size is the same height - and\n * `data-density` on a container reaches the button along with the field. They\n * used to say `h-9` and `h-7` literally, and a compact form came out with 32px\n * fields beside 36px buttons. `xs` is below the rows on purpose: it is for a\n * button inside something - a chat line, a chip, a table cell - and it grows\n * its hit area to the pointer floor rather than its box.\n *\n * **Every size says how big an icon is.** A text button used to size nothing,\n * so a lucide icon inside one drew at its own 24px - taller than the text,\n * across the whole line, and fixed at each call site by a `size-4` that the\n * next call site forgot. Each size now sizes an svg inside it, and only one\n * that has no size of its own: `[&_svg:not([class*=size-])]`. The guard is\n * not a nicety. The unguarded form the icon sizes used to have is a\n * descendant selector, one class and one element, and it outranks the single\n * class `size-5` written on the icon - so an explicit size at a call site was\n * silently overruled, and a `+` meant to be 10px drew at 14. The value is\n * unquoted (`size-` is an identifier) so the class sits in a plain string.\n */\nexport const buttonVariants = cva(\n [\n 'inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap',\n 'font-medium transition-colors',\n // Disabled is a state, not a colour: the button keeps its own hue and\n // loses contact instead, which reads the same whatever the accent is.\n 'disabled:pointer-events-none disabled:opacity-50',\n '[&_svg]:shrink-0',\n ],\n {\n variants: {\n variant: {\n primary: 'rounded-md bg-accent font-semibold text-on-accent hover:bg-accent-2',\n ghost: 'rounded-md border border-line text-dim hover:border-line-2 hover:text-text',\n soft: 'rounded-md bg-accent-soft text-accent hover:bg-accent-soft/60',\n danger: 'rounded-md text-bad hover:bg-bad-soft',\n icon: 'rounded-md text-dim hover:bg-soft hover:text-text',\n },\n size: {\n xs: 'target-min h-6 gap-1 px-2 text-xs [&_svg:not([class*=size-])]:size-3',\n sm: 'h-control-sm px-2.5 text-xs [&_svg:not([class*=size-])]:size-3.5',\n md: 'h-control px-3.5 text-sm [&_svg:not([class*=size-])]:size-4',\n 'icon-xs': 'target-min size-5 [&_svg:not([class*=size-])]:size-3',\n 'icon-sm': 'size-7 [&_svg:not([class*=size-])]:size-3.5',\n 'icon-md': 'size-8 [&_svg:not([class*=size-])]:size-4',\n },\n },\n defaultVariants: { variant: 'ghost', size: 'md' },\n },\n)\n\nexport interface ButtonProps\n extends ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n /**\n * Render something else with the button's clothes on - a link, most often.\n *\n * Takes the element itself rather than a boolean: `render={<a href=\"…\" />}`.\n * A function is also accepted, for the rare case that needs the props\n * before deciding what to build with them.\n */\n render?: useRender.RenderProp\n}\n\nexport function Button({ variant, size, render, className, type, ...props }: ButtonProps) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n // A `<button>` inside a form submits it unless told otherwise, which\n // surprises everyone once. When rendering as something else the\n // attribute is meaningless and would land on an `<a>`, so it is only\n // set for the element that has it - `render` is what says which.\n ...(render === undefined && type === undefined ? { type: 'button' } : { type }),\n className: cn(buttonVariants({ variant, size }), className),\n ...props,\n },\n })\n}\n"
532
573
  }
533
574
  ]
534
575
  },
@@ -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.32.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.32.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.32.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.32.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.32.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.32.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.32.0"
657
717
  ],
658
718
  "registryDependencies": [],
659
719
  "files": [
@@ -673,10 +733,11 @@
673
733
  "dependencies": [
674
734
  "@base-ui/react",
675
735
  "class-variance-authority",
676
- "dowel-ui@^0.30.0"
736
+ "dowel-ui@^0.32.0"
677
737
  ],
678
738
  "registryDependencies": [
679
739
  "https://lacodda.github.io/dowel/r/input.json",
740
+ "https://lacodda.github.io/dowel/r/layer.json",
680
741
  "https://lacodda.github.io/dowel/r/select.json"
681
742
  ],
682
743
  "files": [
@@ -684,7 +745,7 @@
684
745
  "path": "ui/combobox.tsx",
685
746
  "target": "@ui/combobox.tsx",
686
747
  "type": "registry:ui",
687
- "content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
748
+ "content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
688
749
  }
689
750
  ]
690
751
  },
@@ -696,18 +757,19 @@
696
757
  "dependencies": [
697
758
  "@base-ui/react",
698
759
  "class-variance-authority",
699
- "dowel-ui@^0.30.0"
760
+ "dowel-ui@^0.32.0"
700
761
  ],
701
762
  "registryDependencies": [
702
763
  "https://lacodda.github.io/dowel/r/combobox.json",
703
- "https://lacodda.github.io/dowel/r/kbd.json"
764
+ "https://lacodda.github.io/dowel/r/kbd.json",
765
+ "https://lacodda.github.io/dowel/r/layer.json"
704
766
  ],
705
767
  "files": [
706
768
  {
707
769
  "path": "ui/command-palette.tsx",
708
770
  "target": "@ui/command-palette.tsx",
709
771
  "type": "registry:ui",
710
- "content": "import type { ReactNode } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n md: 'max-h-[24rem]',\n lg: 'max-h-[32rem]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ.\n *\n * They did differ, for as long as this was a bare re-export: the comment said\n * \"the same clothes\" and the component wore none, so the rows inherited the\n * popup's 16px and stood a third taller than every other list in the set. A\n * live run caught it - the palette looked like a different product. */\nexport function CommandPaletteItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** The list. Takes a render function over the filtered items. */\nexport function CommandPaletteList({ className, ...props }: Base.List.Props) {\n return <Base.List className={cn('overflow-y-auto p-1', className)} {...props} />\n}\n\n/** Shown when nothing matches. The words are the product's.\n *\n * Base UI keeps it mounted so the announcement fires, which means its padding\n * is spent whether or not it has anything to say - and a palette with six\n * results had a 48px hole under the field. It collapses when empty instead. */\nexport function CommandPaletteEmpty({ className, ...props }: Base.Empty.Props) {\n return (\n <Base.Empty\n className={cn('px-2 py-3 text-center text-sm text-faint empty:hidden empty:p-0', className)}\n {...props}\n />\n )\n}\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n /* The anchor is a point at the top of the viewport, given explicitly.\n *\n * A positioner places a popup against an anchor and hides itself with\n * an inline `opacity: 0` until it has measured one. A palette has no\n * trigger to point at - it is opened by a keystroke - so without this\n * the measure never resolves: the popup sits in the DOM at the right\n * size, fully transparent, rendering nothing and reporting no error.\n * Found by reading the computed style off the positioner rather than\n * the popup, which was opaque the whole time.\n *\n * A zero-height rectangle a fifth of the way down puts the palette\n * where the eye already is rather than dead centre. */\n anchor={{\n getBoundingClientRect: () => {\n const width = typeof window === 'undefined' ? 0 : window.innerWidth\n const top = typeof window === 'undefined' ? 0 : window.innerHeight * 0.18\n return new DOMRect(width / 2, top, 0, 0)\n },\n }}\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `['Esc']`. Decorative. */\n hint?: string[]\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n 'h-11 w-full bg-transparent text-sm text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
772
+ "content": "import { useRef, type ReactNode } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\nimport { LayerProvider } from './layer'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n md: 'max-h-[24rem]',\n lg: 'max-h-[32rem]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ.\n *\n * They did differ, for as long as this was a bare re-export: the comment said\n * \"the same clothes\" and the component wore none, so the rows inherited the\n * popup's 16px and stood a third taller than every other list in the set. A\n * live run caught it - the palette looked like a different product. */\nexport function CommandPaletteItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** The list. Takes a render function over the filtered items. */\nexport function CommandPaletteList({ className, ...props }: Base.List.Props) {\n return <Base.List className={cn('overflow-y-auto p-1', className)} {...props} />\n}\n\n/** Shown when nothing matches. The words are the product's.\n *\n * Base UI keeps it mounted so the announcement fires, which means its padding\n * is spent whether or not it has anything to say - and a palette with six\n * results had a 48px hole under the field. It collapses when empty instead. */\nexport function CommandPaletteEmpty({ className, ...props }: Base.Empty.Props) {\n return (\n <Base.Empty\n className={cn('px-2 py-3 text-center text-sm text-faint empty:hidden empty:p-0', className)}\n {...props}\n />\n )\n}\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n /* The anchor is a point at the top of the viewport, given explicitly.\n *\n * A positioner places a popup against an anchor and hides itself with\n * an inline `opacity: 0` until it has measured one. A palette has no\n * trigger to point at - it is opened by a keystroke - so without this\n * the measure never resolves: the popup sits in the DOM at the right\n * size, fully transparent, rendering nothing and reporting no error.\n * Found by reading the computed style off the positioner rather than\n * the popup, which was opaque the whole time.\n *\n * A zero-height rectangle a fifth of the way down puts the palette\n * where the eye already is rather than dead centre. */\n anchor={{\n getBoundingClientRect: () => {\n const width = typeof window === 'undefined' ? 0 : window.innerWidth\n const top = typeof window === 'undefined' ? 0 : window.innerHeight * 0.18\n return new DOMRect(width / 2, top, 0, 0)\n },\n }}\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {/* A tooltip or a menu opened from a result rides above the palette,\n * which stands above every modal. See `layer.tsx`. */}\n <LayerProvider above=\"palette\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `['Esc']`. Decorative. */\n hint?: string[]\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n 'h-11 w-full bg-transparent text-sm text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
711
773
  }
712
774
  ]
713
775
  },
@@ -719,15 +781,18 @@
719
781
  "dependencies": [
720
782
  "@base-ui/react",
721
783
  "class-variance-authority",
722
- "dowel-ui@^0.30.0"
784
+ "dowel-ui@^0.32.0"
785
+ ],
786
+ "registryDependencies": [
787
+ "https://lacodda.github.io/dowel/r/dialog.json",
788
+ "https://lacodda.github.io/dowel/r/layer.json"
723
789
  ],
724
- "registryDependencies": [],
725
790
  "files": [
726
791
  {
727
792
  "path": "ui/confirm-dialog.tsx",
728
793
  "target": "@ui/confirm-dialog.tsx",
729
794
  "type": "registry:ui",
730
- "content": "import { AlertDialog as Base } from '@base-ui/react/alert-dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * ConfirmDialog.\n *\n * The dialog for a choice that cannot be taken back - deleting, discarding,\n * revoking. It looks almost exactly like Dialog, and that is deliberate: the\n * difference is not clothes, it is what the popup is allowed to do.\n *\n * A Dialog is dismissed by clicking away, because a Dialog is a place the user\n * wandered into. This one is not: Base UI's `AlertDialog` announces itself as\n * `role=\"alertdialog\"`, which tells a screen reader the popup is interrupting\n * rather than presenting, and it forces `modal` and `disablePointerDismissal`\n * on - `AlertDialog.Root` omits both from Dialog's props, so there is nothing\n * to turn off. A press outside does nothing at all.\n *\n * `Escape` still closes it, and that is deliberate rather than an oversight: a\n * popup with no keyboard way out is a trap. The difference is between a\n * deliberate keypress and clicking absentmindedly beside a dialog.\n *\n * So the rule for choosing between the two is not how important the content\n * feels. It is whether dismissing it by a stray click would be a loss.\n */\n\nexport const confirmDialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'rounded-xl border border-line bg-raise p-5 text-text shadow-float',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(22rem,calc(100vw-2rem))]',\n md: 'w-[min(26rem,calc(100vw-2rem))]',\n lg: 'w-[min(34rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `ConfirmDialogTrigger`. */\nexport const ConfirmDialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const ConfirmDialogTrigger = Base.Trigger\n\n/** What closes it - and the only thing that does, which is why a confirm\n * dialog with no `ConfirmDialogClose` inside it is a trap. */\nexport const ConfirmDialogClose = Base.Close\n\n/** The scrim. Darker than the Dialog's, because what is behind it is not just\n * out of reach for a moment - it is waiting on an answer. */\nexport function ConfirmDialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/60 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface ConfirmDialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof confirmDialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself, announced as `alertdialog`. Portalled, so it is not\n * clipped by whatever it was opened from. */\nexport function ConfirmDialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: ConfirmDialogPopupProps) {\n return (\n <Base.Portal container={container}>\n {backdrop && <ConfirmDialogBackdrop />}\n <Base.Popup\n className={cn(\n confirmDialogPopupVariants({ size }),\n '[z-index:var(--z-modal)]',\n className,\n )}\n {...props}\n >\n {children}\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/** The question. Base UI points the popup's `aria-labelledby` at it, so this\n * is what a screen reader reads out when the dialog interrupts. */\nexport function ConfirmDialogTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** What the answer costs, and the popup's `aria-describedby`. On this dialog\n * it is close to required: the title asks, the description says what happens. */\nexport function ConfirmDialogDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the two answers go. Right-aligned, because the choice belongs where\n * the eye leaves the sentence. */\nexport function ConfirmDialogActions({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-5 flex justify-end gap-2', className)} {...props} />\n}\n"
795
+ "content": "import { useRef } from 'react'\nimport { AlertDialog as Base } from '@base-ui/react/alert-dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { DialogActions, DialogBody, DialogHeader } from './dialog'\nimport { LayerProvider } from './layer'\n\n/*\n * ConfirmDialog.\n *\n * The dialog for a choice that cannot be taken back - deleting, discarding,\n * revoking. It looks almost exactly like Dialog, and that is deliberate: the\n * difference is not clothes, it is what the popup is allowed to do.\n *\n * A Dialog is dismissed by clicking away, because a Dialog is a place the user\n * wandered into. This one is not: Base UI's `AlertDialog` announces itself as\n * `role=\"alertdialog\"`, which tells a screen reader the popup is interrupting\n * rather than presenting, and it forces `modal` and `disablePointerDismissal`\n * on - `AlertDialog.Root` omits both from Dialog's props, so there is nothing\n * to turn off. A press outside does nothing at all.\n *\n * `Escape` still closes it, and that is deliberate rather than an oversight: a\n * popup with no keyboard way out is a trap. The difference is between a\n * deliberate keypress and clicking absentmindedly beside a dialog.\n *\n * So the rule for choosing between the two is not how important the content\n * feels. It is whether dismissing it by a stray click would be a loss.\n *\n * The anatomy is the dialog's, taken from it: a header, a body that scrolls\n * if the consequences run long, and the two answers pinned to the bottom. It\n * has the dialog's small sizes and not its large ones on purpose - a question\n * that needs an editor's width to be asked is an editor, and belongs in a\n * Dialog.\n */\n\nexport const confirmDialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'flex flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n // Never taller than the window, which this one used not to promise: a\n // long list of what a deletion takes with it pushed both answers off\n // the bottom edge.\n 'max-h-[calc(100dvh-2rem)]',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(22rem,calc(100vw-2rem))]',\n md: 'w-[min(26rem,calc(100vw-2rem))]',\n lg: 'w-[min(34rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `ConfirmDialogTrigger`. */\nexport const ConfirmDialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const ConfirmDialogTrigger = Base.Trigger\n\n/** What closes it - and the only thing that does, which is why a confirm\n * dialog with no `ConfirmDialogClose` inside it is a trap. */\nexport const ConfirmDialogClose = Base.Close\n\n/** The scrim. Darker than the Dialog's, because what is behind it is not just\n * out of reach for a moment - it is waiting on an answer. */\nexport function ConfirmDialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/60 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface ConfirmDialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof confirmDialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself, announced as `alertdialog`. Portalled, so it is not\n * clipped by whatever it was opened from. */\nexport function ConfirmDialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: ConfirmDialogPopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n {backdrop && <ConfirmDialogBackdrop />}\n <Base.Popup\n className={cn(\n confirmDialogPopupVariants({ size }),\n '[z-index:var(--z-modal)]',\n className,\n )}\n {...props}\n >\n <LayerProvider above=\"modal\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/** The question. Base UI points the popup's `aria-labelledby` at it, so this\n * is what a screen reader reads out when the dialog interrupts. */\nexport function ConfirmDialogTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** What the answer costs, and the popup's `aria-describedby`. On this dialog\n * it is close to required: the title asks, the description says what happens. */\nexport function ConfirmDialogDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** The top: the question and what it costs. The dialog's part, under this\n * one's name. */\nexport const ConfirmDialogHeader = DialogHeader\n\n/** The part that scrolls, for consequences too long to fit. The dialog's\n * part, under this one's name. */\nexport const ConfirmDialogBody = DialogBody\n\n/** Where the two answers go, pinned to the bottom and right-aligned, because\n * the choice belongs where the eye leaves the sentence. The dialog's part,\n * under this one's name. */\nexport const ConfirmDialogActions = DialogActions\n"
731
796
  }
732
797
  ]
733
798
  },
@@ -738,9 +803,10 @@
738
803
  "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
804
  "dependencies": [
740
805
  "@base-ui/react",
741
- "dowel-ui@^0.30.0"
806
+ "dowel-ui@^0.32.0"
742
807
  ],
743
808
  "registryDependencies": [
809
+ "https://lacodda.github.io/dowel/r/layer.json",
744
810
  "https://lacodda.github.io/dowel/r/menu.json"
745
811
  ],
746
812
  "files": [
@@ -748,7 +814,7 @@
748
814
  "path": "ui/context-menu.tsx",
749
815
  "target": "@ui/context-menu.tsx",
750
816
  "type": "registry:ui",
751
- "content": "import { ContextMenu as Base } from '@base-ui/react/context-menu'\nimport { menuItemVariants, menuPopupVariants } from './menu'\nimport { cn } from 'dowel-ui'\n\n/*\n * ContextMenu.\n *\n * The same list of actions as Menu, opened the other way round: by right\n * click, or by a long press on a touch screen, over an *area* rather than\n * from a button. So the trigger is not a control - it is the region the menu\n * belongs to, a row, a canvas, a file tile - and it renders a `<div>`.\n *\n * That difference is the whole component. Everything below the Root is Menu's\n * own: Base UI re-exports the Portal, Positioner, Popup, Item and the rest\n * from the menu package, so the popup that opens here is literally the same\n * popup, with the same keyboard, the same type-ahead and the same submenus.\n *\n * Which is why the clothes are imported rather than copied. Two `cva` calls\n * that started identical drift within a release - one gets a padding fix, the\n * other does not - and then a product has two menus that are almost the same,\n * which is worse than two that differ on purpose.\n *\n * What Base UI does *not* give this one is `openOnHover`, `modal` or a\n * `handle`: a context menu is opened by a gesture over an anchor point, not\n * by a trigger element it can be attached to from elsewhere.\n */\n\n/** The root. It positions against the pointer, so there is nothing to anchor\n * and nothing to control but `onOpenChange`. */\nexport const ContextMenu = Base.Root\n\n/** The area that opens it. A `<div>`, not a button: give it `render` to make\n * it the row or the canvas it belongs to rather than a wrapper. */\nexport const ContextMenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const ContextMenuGroup = Base.Group\n\n/** A submenu, opened from a `ContextMenuSubTrigger` inside the parent. */\nexport const ContextMenuSub = Base.SubmenuRoot\n\nexport interface ContextMenuPopupProps extends Base.Popup.Props {\n /** How wide the popup starts. The same three as Menu's. */\n size?: 'sm' | 'md' | 'lg'\n /** Where to portal to. Defaults to the document body, which keeps the menu\n * from being clipped by the very row it was opened over. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled, and positioned against the point that was clicked\n * rather than against an element. */\nexport function ContextMenuPopup({\n size,\n container,\n className,\n children,\n ...props\n}: ContextMenuPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner className=\"[z-index:var(--z-menu)]\">\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface ContextMenuItemProps extends Base.Item.Props {\n /** `danger` draws the destructive one apart, in the colour of something\n * that cannot be undone. */\n tone?: 'default' | 'danger'\n}\n\n/** An action. */\nexport function ContextMenuItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function ContextMenuSubTrigger({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's. */\nexport function ContextMenuCheckboxItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const ContextMenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such. */\nexport function ContextMenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ContextMenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
817
+ "content": "import { ContextMenu as Base } from '@base-ui/react/context-menu'\nimport { menuItemVariants, menuPopupVariants } from './menu'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * ContextMenu.\n *\n * The same list of actions as Menu, opened the other way round: by right\n * click, or by a long press on a touch screen, over an *area* rather than\n * from a button. So the trigger is not a control - it is the region the menu\n * belongs to, a row, a canvas, a file tile - and it renders a `<div>`.\n *\n * That difference is the whole component. Everything below the Root is Menu's\n * own: Base UI re-exports the Portal, Positioner, Popup, Item and the rest\n * from the menu package, so the popup that opens here is literally the same\n * popup, with the same keyboard, the same type-ahead and the same submenus.\n *\n * Which is why the clothes are imported rather than copied. Two `cva` calls\n * that started identical drift within a release - one gets a padding fix, the\n * other does not - and then a product has two menus that are almost the same,\n * which is worse than two that differ on purpose.\n *\n * What Base UI does *not* give this one is `openOnHover`, `modal` or a\n * `handle`: a context menu is opened by a gesture over an anchor point, not\n * by a trigger element it can be attached to from elsewhere.\n */\n\n/** The root. It positions against the pointer, so there is nothing to anchor\n * and nothing to control but `onOpenChange`. */\nexport const ContextMenu = Base.Root\n\n/** The area that opens it. A `<div>`, not a button: give it `render` to make\n * it the row or the canvas it belongs to rather than a wrapper. */\nexport const ContextMenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const ContextMenuGroup = Base.Group\n\n/** A submenu, opened from a `ContextMenuSubTrigger` inside the parent. */\nexport const ContextMenuSub = Base.SubmenuRoot\n\nexport interface ContextMenuPopupProps extends Base.Popup.Props {\n /** How wide the popup starts. The same three as Menu's. */\n size?: 'sm' | 'md' | 'lg'\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled, and positioned against the point that was clicked\n * rather than against an element. */\nexport function ContextMenuPopup({\n size,\n container,\n className,\n children,\n ...props\n}: ContextMenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner className=\"[z-index:var(--z-menu)]\">\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface ContextMenuItemProps extends Base.Item.Props {\n /** `danger` draws the destructive one apart, in the colour of something\n * that cannot be undone. */\n tone?: 'default' | 'danger'\n}\n\n/** An action. */\nexport function ContextMenuItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function ContextMenuSubTrigger({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's. */\nexport function ContextMenuCheckboxItem({ tone, className, ...props }: ContextMenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const ContextMenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such. */\nexport function ContextMenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ContextMenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
752
818
  }
753
819
  ]
754
820
  },
@@ -758,7 +824,7 @@
758
824
  "title": "CopyButton",
759
825
  "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
826
  "dependencies": [
761
- "dowel-ui@^0.30.0"
827
+ "dowel-ui@^0.32.0"
762
828
  ],
763
829
  "registryDependencies": [],
764
830
  "files": [
@@ -776,7 +842,7 @@
776
842
  "title": "Copyable",
777
843
  "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
844
  "dependencies": [
779
- "dowel-ui@^0.30.0"
845
+ "dowel-ui@^0.32.0"
780
846
  ],
781
847
  "registryDependencies": [],
782
848
  "files": [
@@ -794,7 +860,7 @@
794
860
  "title": "DatePicker",
795
861
  "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
862
  "dependencies": [
797
- "dowel-ui@^0.30.0"
863
+ "dowel-ui@^0.32.0"
798
864
  ],
799
865
  "registryDependencies": [
800
866
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -807,7 +873,7 @@
807
873
  "path": "ui/date-picker.tsx",
808
874
  "target": "@ui/date-picker.tsx",
809
875
  "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"
876
+ "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
877
  }
812
878
  ]
813
879
  },
@@ -817,7 +883,7 @@
817
883
  "title": "DateRangePicker",
818
884
  "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
885
  "dependencies": [
820
- "dowel-ui@^0.30.0"
886
+ "dowel-ui@^0.32.0"
821
887
  ],
822
888
  "registryDependencies": [
823
889
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -830,7 +896,7 @@
830
896
  "path": "ui/date-range-picker.tsx",
831
897
  "target": "@ui/date-range-picker.tsx",
832
898
  "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"
899
+ "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
900
  }
835
901
  ]
836
902
  },
@@ -842,15 +908,17 @@
842
908
  "dependencies": [
843
909
  "@base-ui/react",
844
910
  "class-variance-authority",
845
- "dowel-ui@^0.30.0"
911
+ "dowel-ui@^0.32.0"
912
+ ],
913
+ "registryDependencies": [
914
+ "https://lacodda.github.io/dowel/r/layer.json"
846
915
  ],
847
- "registryDependencies": [],
848
916
  "files": [
849
917
  {
850
918
  "path": "ui/dialog.tsx",
851
919
  "target": "@ui/dialog.tsx",
852
920
  "type": "registry:ui",
853
- "content": "import { Dialog as Base } from '@base-ui/react/dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Dialog.\n *\n * The parts are exposed rather than wrapped in one component with `title` and\n * `footer` props. The products that did it the other way ended up passing\n * `footer={<>…</>}` within a week, which is a slot with extra steps - and a\n * dialog that owns its own close button owns a word for it, which is a word\n * the product cannot translate.\n *\n * Behaviour is Base UI's: the focus trap, the return of focus to whatever\n * opened it, `Escape`, the scroll lock, and the `aria-labelledby` that ties\n * the popup to its own title. None of that is worth rewriting, and all of it\n * is wrong in the ways nobody notices until someone is navigating by keyboard.\n *\n * What is ours is the clothes, and the enter and leave: the popup arrives with\n * `data-open` and leaves with `data-closed`, both of which Base UI sets, so\n * the animation is a class list rather than a state machine.\n */\n\nexport const dialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'rounded-xl border border-line bg-raise p-5 text-text shadow-float',\n 'focus-visible:outline-none',\n // The width was capped from the start and the height was not, so a dialog\n // with more in it than the window is tall centred itself and hung off both\n // ends - the top out of reach above the viewport, the buttons below it.\n // Found on a release editor in kilna, which is exactly the shape that does\n // it: half a dozen fields and a row of actions.\n 'max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(24rem,calc(100vw-2rem))]',\n md: 'w-[min(28rem,calc(100vw-2rem))]',\n lg: 'w-[min(40rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `Dialog.Trigger`. */\nexport const Dialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DialogTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DialogClose = Base.Close\n\n/** The scrim. Dark because a modal is a modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof dialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by whatever it was opened from. Pass an\n * element to put it somewhere else - inside a dialog that is already open,\n * or into a container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself. Portalled, so it is not clipped by whatever it was\n * opened from. */\nexport function DialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: DialogPopupProps) {\n return (\n <Base.Portal container={container}>\n {backdrop && <DialogBackdrop />}\n <Base.Popup\n className={cn(dialogPopupVariants({ size }), '[z-index:var(--z-modal)]', className)}\n {...props}\n >\n {children}\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * dialog with one is named for a screen reader without anyone arranging it. */\nexport function DialogTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DialogDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the actions go. Right-aligned, because the primary action of a\n * dialog belongs where the eye leaves the sentence. */\nexport function DialogActions({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-5 flex justify-end gap-2', className)} {...props} />\n}\n"
921
+ "content": "import { useRef, type HTMLAttributes, type ReactNode } from 'react'\nimport { Dialog as Base } from '@base-ui/react/dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { LayerProvider } from './layer'\n\n/*\n * Dialog.\n *\n * The parts are exposed rather than wrapped in one component with `title` and\n * `footer` props. The products that did it the other way ended up passing\n * `footer={<>…</>}` within a week, which is a slot with extra steps - and a\n * dialog that owns its own close button owns a word for it, which is a word\n * the product cannot translate.\n *\n * Behaviour is Base UI's: the focus trap, the return of focus to whatever\n * opened it, `Escape`, the scroll lock, and the `aria-labelledby` that ties\n * the popup to its own title. None of that is worth rewriting, and all of it\n * is wrong in the ways nobody notices until someone is navigating by keyboard.\n *\n * What is ours is the clothes, the enter and leave - the popup arrives with\n * `data-open` and leaves with `data-closed`, both of which Base UI sets, so\n * the animation is a class list rather than a state machine - and the anatomy.\n *\n * **Only the body scrolls.** The popup is a column of three parts: a header\n * that stays at the top, a body that takes what height is left and scrolls\n * inside it, and the actions pinned to the bottom. The popup used to scroll as\n * a whole, and a dialog with more in it than the window is tall took its own\n * buttons with it: in kilna, at a 1280x720 window, \"Save\" on the style editor\n * stood 137px below the bottom edge, and the only way to reach it was to\n * scroll a form to its end to find out whether it had one. The popup carries\n * no padding of its own now - each part does - so the body's scroll runs edge\n * to edge and the header and actions stay whole while it moves.\n *\n * **A popup opened inside rides above it.** The content sits in a layer\n * (`layer.tsx`): a select, a menu or a date picker in a dialog portals into a\n * host raised one rung above the modal, so it is neither clipped by the\n * body's scroll nor drawn underneath the dialog that opened it.\n */\n\nexport const dialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'flex flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n // Never taller than the window. The width was capped from the start and\n // the height was not, so a dialog taller than the window centred itself\n // and hung off both ends. The cap is on the column; the body is what\n // gives way.\n 'max-h-[calc(100dvh-2rem)]',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(24rem,calc(100vw-2rem))]',\n md: 'w-[min(28rem,calc(100vw-2rem))]',\n lg: 'w-[min(40rem,calc(100vw-2rem))]',\n // An editor: a form wide enough for its fields to sit side by side,\n // or a row of choices to fit on one line rather than three.\n xl: 'w-[min(48rem,calc(100vw-2rem))]',\n // The window, less a margin that says it is still a dialog. The\n // height is set rather than capped, so the body fills it even when\n // there is little in it - a picker or a preview should not jump in\n // size as its content loads.\n full: 'h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `Dialog.Trigger`. */\nexport const Dialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DialogTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DialogClose = Base.Close\n\n/** The scrim. Dark because a modal is a modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof dialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by whatever it was opened from. Pass an\n * element to put it somewhere else - inside a dialog that is already open,\n * or into a container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself. Portalled, so it is not clipped by whatever it was\n * opened from. Put a `DialogHeader`, a `DialogBody` and `DialogActions`\n * inside it. */\nexport function DialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: DialogPopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n {backdrop && <DialogBackdrop />}\n <Base.Popup\n className={cn(dialogPopupVariants({ size }), '[z-index:var(--z-modal)]', className)}\n {...props}\n >\n <LayerProvider above=\"modal\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/*\n * The three parts. Plain elements rather than Base UI parts, because what\n * they do is layout: which one stays and which one scrolls. That is also why\n * the drawer and the confirm dialog take them from here - the anatomy is one\n * decision, and three copies of it would be three answers by the next\n * version.\n */\n\nexport interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {\n /** Beside the title, at the end of the line: a close button, a menu. The\n * title and description wrap under themselves rather than under it. */\n action?: ReactNode\n}\n\n/** The top of the popup: the title, the line under it, and an action beside\n * them. It stays put while the body scrolls. */\nexport function DialogHeader({ action, className, children, ...props }: DialogHeaderProps) {\n return (\n <div className={cn('flex shrink-0 items-start gap-3 px-5 pt-5 pb-4', className)} {...props}>\n <div className=\"min-w-0 flex-1\">{children}</div>\n {action !== undefined && <div className=\"-mt-1 -mr-1.5 flex shrink-0 items-center gap-1\">{action}</div>}\n </div>\n )\n}\n\n/** The part that scrolls. It takes the height the header and the actions\n * leave, and only it moves when there is more than fits. */\nexport function DialogBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-5',\n // First or last in the popup, it takes the padding the missing part\n // would have given - a dialog with no header still has a top edge.\n 'first:pt-5 last:pb-5',\n // A box that scrolls can be focused, and the popup clips what is\n // outside it: the ring is drawn inside.\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogActionsProps extends HTMLAttributes<HTMLDivElement> {\n /** At the other end of the row from the answer: the action that is not the\n * answer to the dialog's question - \"Delete\" on an editor whose question is\n * \"Save?\". Kept apart so it is not pressed on the way to \"Save\". */\n start?: ReactNode\n}\n\n/** Where the actions go, pinned to the bottom. Right-aligned, because the\n * primary action of a dialog belongs where the eye leaves the sentence. */\nexport function DialogActions({ start, className, children, ...props }: DialogActionsProps) {\n return (\n <div\n className={cn('mt-auto flex shrink-0 items-center justify-end gap-2 px-5 pt-4 pb-5', className)}\n {...props}\n >\n {start !== undefined && <div className=\"mr-auto flex items-center gap-2\">{start}</div>}\n {children}\n </div>\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * dialog with one is named for a screen reader without anyone arranging it. */\nexport function DialogTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DialogDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
854
922
  }
855
923
  ]
856
924
  },
@@ -877,7 +945,7 @@
877
945
  "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
946
  "dependencies": [
879
947
  "class-variance-authority",
880
- "dowel-ui@^0.30.0"
948
+ "dowel-ui@^0.32.0"
881
949
  ],
882
950
  "registryDependencies": [
883
951
  "https://lacodda.github.io/dowel/r/copy-button.json",
@@ -899,7 +967,7 @@
899
967
  "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
968
  "dependencies": [
901
969
  "class-variance-authority",
902
- "dowel-ui@^0.30.0"
970
+ "dowel-ui@^0.32.0"
903
971
  ],
904
972
  "registryDependencies": [],
905
973
  "files": [
@@ -919,15 +987,18 @@
919
987
  "dependencies": [
920
988
  "@base-ui/react",
921
989
  "class-variance-authority",
922
- "dowel-ui@^0.30.0"
990
+ "dowel-ui@^0.32.0"
991
+ ],
992
+ "registryDependencies": [
993
+ "https://lacodda.github.io/dowel/r/dialog.json",
994
+ "https://lacodda.github.io/dowel/r/layer.json"
923
995
  ],
924
- "registryDependencies": [],
925
996
  "files": [
926
997
  {
927
998
  "path": "ui/drawer.tsx",
928
999
  "target": "@ui/drawer.tsx",
929
1000
  "type": "registry:ui",
930
- "content": "import { Drawer as Base } from '@base-ui/react/drawer'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drawer.\n *\n * A panel that slides in from an edge and holds the screen while it is there -\n * a filter sheet, a detail pane, a form too long to centre. Modal like Dialog,\n * placed like nothing else.\n *\n * Base UI positions none of it. Unlike Popover there is no Positioner and no\n * anchor to measure against; the edge it comes from is entirely CSS, which is\n * what the `side` variant is. It drives three things at once and they have to\n * agree: where the Viewport pushes the panel, which border it grows against,\n * and which way it is translated while opening and closing.\n *\n * `swipeDirection` is the other half and cannot be inferred here: it lives on\n * the root, while `side` lives on the popup, so the two are set together by\n * hand - `side=\"right\"` with `swipeDirection=\"right\"`, `side=\"bottom\"` with\n * `down`, which is Base UI's default. Left unmatched, the drawer slides in\n * from one edge and is flicked away towards another.\n *\n * The transitions key off `data-starting-style` and `data-ending-style` rather\n * than `data-closed`. That is Base UI's own convention for the drawer, and the\n * reason is that a drawer is dragged as well as animated: the popup carries a\n * live `--drawer-swipe-movement-*` while a finger is on it, and the transform\n * has to compose with that rather than replace it.\n */\n\n/** The Viewport: fixed to the whole window, pushing the popup to one edge.\n *\n * It is separate from the popup because a drawer that is its own positioner\n * cannot be scrolled independently of where it sits - and the panel needs to\n * scroll while the edge it is pinned to does not move. */\nconst drawerViewportVariants = cva('fixed inset-0 flex', {\n variants: {\n side: {\n right: 'justify-end',\n left: 'justify-start',\n bottom: 'items-end',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nexport const drawerPopupVariants = cva(\n [\n 'flex flex-col overflow-y-auto overscroll-contain',\n 'border-line bg-raise p-5 text-text shadow-float',\n 'focus-visible:outline-none',\n // Composed with the live swipe offset rather than replacing it, so a\n // half-dragged drawer animates from where the finger left it.\n '[transition:transform_var(--duration-base)_var(--ease-out)]',\n 'will-change-transform',\n ],\n {\n variants: {\n /* Where it comes from. The three the products of the line reach for:\n * a right-hand pane, its mirror, and a bottom sheet. */\n side: {\n right: [\n 'h-full w-[min(24rem,calc(100vw-3rem))] border-l',\n '[transform:translateX(var(--drawer-swipe-movement-x))]',\n 'data-[starting-style]:[transform:translateX(100%)]',\n 'data-[ending-style]:[transform:translateX(100%)]',\n ],\n left: [\n 'h-full w-[min(24rem,calc(100vw-3rem))] border-r',\n '[transform:translateX(var(--drawer-swipe-movement-x))]',\n 'data-[starting-style]:[transform:translateX(-100%)]',\n 'data-[ending-style]:[transform:translateX(-100%)]',\n ],\n bottom: [\n 'max-h-[80vh] w-full rounded-t-xl border-t',\n '[transform:translateY(var(--drawer-swipe-movement-y))]',\n 'data-[starting-style]:[transform:translateY(100%)]',\n 'data-[ending-style]:[transform:translateY(100%)]',\n ],\n },\n },\n defaultVariants: { side: 'right' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `DrawerTrigger`. */\nexport const Drawer = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DrawerTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DrawerClose = Base.Close\n\n/** The scrim. Dark because a drawer is modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DrawerBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DrawerPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof drawerPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The panel. Portalled, and wrapped in its own viewport so the edge it is\n * pinned to holds still while the contents scroll. */\nexport function DrawerPopup({\n container,\n backdrop = true,\n side,\n className,\n children,\n ...props\n}: DrawerPopupProps) {\n return (\n <Base.Portal container={container}>\n {backdrop && <DrawerBackdrop />}\n <div className={cn(drawerViewportVariants({ side }), '[z-index:var(--z-modal)]')}>\n <Base.Viewport className=\"flex w-full\">\n <Base.Popup\n className={cn(drawerPopupVariants({ side }), className)}\n {...props}\n >\n {children}\n </Base.Popup>\n </Base.Viewport>\n </div>\n </Base.Portal>\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * drawer with one is named for a screen reader without anyone arranging it. */\nexport function DrawerTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DrawerDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the actions go. Pushed to the bottom of the panel rather than sitting\n * under the content, because a drawer is tall and its buttons should not\n * wander up the page when there is little in it. */\nexport function DrawerActions({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-auto flex justify-end gap-2 pt-5', className)} {...props} />\n}\n"
1001
+ "content": "import { useRef } from 'react'\nimport { Drawer as Base } from '@base-ui/react/drawer'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { DialogActions, DialogBody, DialogHeader } from './dialog'\nimport { LayerProvider } from './layer'\n\n/*\n * Drawer.\n *\n * A panel that slides in from an edge and holds the screen while it is there -\n * a filter sheet, a detail pane, a form too long to centre. Modal like Dialog,\n * placed like nothing else.\n *\n * Base UI positions none of it. Unlike Popover there is no Positioner and no\n * anchor to measure against; the edge it comes from is entirely CSS, which is\n * what the `side` variant is. It drives three things at once and they have to\n * agree: where the Viewport pushes the panel, which border it grows against,\n * and which way it is translated while opening and closing.\n *\n * `swipeDirection` is the other half and cannot be inferred here: it lives on\n * the root, while `side` lives on the popup, so the two are set together by\n * hand - `side=\"right\"` with `swipeDirection=\"right\"`, `side=\"bottom\"` with\n * `down`, which is Base UI's default. Left unmatched, the drawer slides in\n * from one edge and is flicked away towards another.\n *\n * The transitions key off `data-starting-style` and `data-ending-style` rather\n * than `data-closed`. That is Base UI's own convention for the drawer, and the\n * reason is that a drawer is dragged as well as animated: the popup carries a\n * live `--drawer-swipe-movement-*` while a finger is on it, and the transform\n * has to compose with that rather than replace it.\n *\n * The anatomy is the dialog's, taken from it rather than copied: a header that\n * stays, a body that scrolls, and the actions pinned to the bottom edge. The\n * panel used to scroll as a whole, which on a tall form took the buttons down\n * with it. A popup opened inside - the menu on a row of the assistant's chat\n * in kilna, which opened underneath the drawer - rides a layer above it.\n */\n\n/** The Viewport: fixed to the whole window, pushing the popup to one edge.\n *\n * It is separate from the popup because a drawer that is its own positioner\n * cannot be scrolled independently of where it sits - and the panel needs to\n * scroll while the edge it is pinned to does not move. */\nconst drawerViewportVariants = cva('fixed inset-0 flex', {\n variants: {\n side: {\n right: 'justify-end',\n left: 'justify-start',\n bottom: 'items-end',\n },\n },\n defaultVariants: { side: 'right' },\n})\n\nexport const drawerPopupVariants = cva(\n [\n 'flex flex-col overflow-hidden',\n 'border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n // Composed with the live swipe offset rather than replacing it, so a\n // half-dragged drawer animates from where the finger left it.\n '[transition:transform_var(--duration-base)_var(--ease-out)]',\n 'will-change-transform',\n ],\n {\n variants: {\n /* Where it comes from. The three the products of the line reach for:\n * a right-hand pane, its mirror, and a bottom sheet. */\n side: {\n right: [\n 'h-full border-l',\n '[transform:translateX(var(--drawer-swipe-movement-x))]',\n 'data-[starting-style]:[transform:translateX(100%)]',\n 'data-[ending-style]:[transform:translateX(100%)]',\n ],\n left: [\n 'h-full border-r',\n '[transform:translateX(var(--drawer-swipe-movement-x))]',\n 'data-[starting-style]:[transform:translateX(-100%)]',\n 'data-[ending-style]:[transform:translateX(-100%)]',\n ],\n bottom: [\n 'w-full rounded-t-xl border-t',\n '[transform:translateY(var(--drawer-swipe-movement-y))]',\n 'data-[starting-style]:[transform:translateY(100%)]',\n 'data-[ending-style]:[transform:translateY(100%)]',\n ],\n },\n /* How much of the window it takes, across the edge it comes from: the\n * width of a side panel, the height of a bottom sheet. A filter sheet\n * and a detail pane are different sizes of the same thing. */\n size: {\n sm: '',\n md: '',\n lg: '',\n xl: '',\n full: '',\n },\n },\n compoundVariants: [\n { side: ['right', 'left'], size: 'sm', className: 'w-[min(20rem,calc(100vw-3rem))]' },\n { side: ['right', 'left'], size: 'md', className: 'w-[min(24rem,calc(100vw-3rem))]' },\n { side: ['right', 'left'], size: 'lg', className: 'w-[min(32rem,calc(100vw-3rem))]' },\n { side: ['right', 'left'], size: 'xl', className: 'w-[min(48rem,calc(100vw-3rem))]' },\n { side: ['right', 'left'], size: 'full', className: 'w-[calc(100vw-3rem)]' },\n // A sheet is capped rather than sized, so a short one stays short - up\n // to `full`, which is set, so the body fills it while content loads.\n { side: 'bottom', size: 'sm', className: 'max-h-[40vh]' },\n { side: 'bottom', size: 'md', className: 'max-h-[80vh]' },\n { side: 'bottom', size: 'lg', className: 'max-h-[90vh]' },\n { side: 'bottom', size: 'xl', className: 'max-h-[calc(100dvh-3rem)]' },\n { side: 'bottom', size: 'full', className: 'h-[calc(100dvh-3rem)]' },\n ],\n defaultVariants: { side: 'right', size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `DrawerTrigger`. */\nexport const Drawer = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DrawerTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DrawerClose = Base.Close\n\n/** The scrim. Dark because a drawer is modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DrawerBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DrawerPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof drawerPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The panel. Portalled, and wrapped in its own viewport so the edge it is\n * pinned to holds still while the body scrolls. Put a `DrawerHeader`, a\n * `DrawerBody` and `DrawerActions` inside it. */\nexport function DrawerPopup({\n container,\n backdrop = true,\n side,\n size,\n className,\n children,\n ...props\n}: DrawerPopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n {backdrop && <DrawerBackdrop />}\n <div className={cn(drawerViewportVariants({ side }), '[z-index:var(--z-modal)]')}>\n <Base.Viewport className=\"flex w-full\">\n <Base.Popup\n className={cn(drawerPopupVariants({ side, size }), className)}\n {...props}\n >\n <LayerProvider above=\"modal\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Viewport>\n </div>\n </Base.Portal>\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * drawer with one is named for a screen reader without anyone arranging it. */\nexport function DrawerTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DrawerDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** The top of the panel: the title, the line under it, and an action beside\n * them. The dialog's part, under the drawer's name. */\nexport const DrawerHeader = DialogHeader\n\n/** The part that scrolls. The dialog's part, under the drawer's name. */\nexport const DrawerBody = DialogBody\n\n/** Where the actions go, pinned to the bottom edge of the panel however\n * little is in it - a drawer is tall, and its buttons should not wander up\n * the page. The dialog's part, under the drawer's name. */\nexport const DrawerActions = DialogActions\n"
931
1002
  }
932
1003
  ]
933
1004
  },
@@ -937,7 +1008,7 @@
937
1008
  "title": "DurationField",
938
1009
  "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
1010
  "dependencies": [
940
- "dowel-ui@^0.30.0"
1011
+ "dowel-ui@^0.32.0"
941
1012
  ],
942
1013
  "registryDependencies": [
943
1014
  "https://lacodda.github.io/dowel/r/input.json"
@@ -958,7 +1029,7 @@
958
1029
  "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
1030
  "dependencies": [
960
1031
  "class-variance-authority",
961
- "dowel-ui@^0.30.0"
1032
+ "dowel-ui@^0.32.0"
962
1033
  ],
963
1034
  "registryDependencies": [],
964
1035
  "files": [
@@ -996,7 +1067,7 @@
996
1067
  "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
1068
  "dependencies": [
998
1069
  "@base-ui/react",
999
- "dowel-ui@^0.30.0"
1070
+ "dowel-ui@^0.32.0"
1000
1071
  ],
1001
1072
  "registryDependencies": [],
1002
1073
  "files": [
@@ -1014,7 +1085,7 @@
1014
1085
  "title": "FileDrop",
1015
1086
  "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
1087
  "dependencies": [
1017
- "dowel-ui@^0.30.0"
1088
+ "dowel-ui@^0.32.0"
1018
1089
  ],
1019
1090
  "registryDependencies": [],
1020
1091
  "files": [
@@ -1032,7 +1103,7 @@
1032
1103
  "title": "FilterPopover",
1033
1104
  "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
1105
  "dependencies": [
1035
- "dowel-ui@^0.30.0"
1106
+ "dowel-ui@^0.32.0"
1036
1107
  ],
1037
1108
  "registryDependencies": [
1038
1109
  "https://lacodda.github.io/dowel/r/button.json",
@@ -1047,13 +1118,32 @@
1047
1118
  }
1048
1119
  ]
1049
1120
  },
1121
+ {
1122
+ "name": "image",
1123
+ "type": "registry:ui",
1124
+ "title": "Image",
1125
+ "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`.",
1126
+ "dependencies": [
1127
+ "class-variance-authority",
1128
+ "dowel-ui@^0.32.0"
1129
+ ],
1130
+ "registryDependencies": [],
1131
+ "files": [
1132
+ {
1133
+ "path": "ui/image.tsx",
1134
+ "target": "@ui/image.tsx",
1135
+ "type": "registry:ui",
1136
+ "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"
1137
+ }
1138
+ ]
1139
+ },
1050
1140
  {
1051
1141
  "name": "input",
1052
1142
  "type": "registry:ui",
1053
1143
  "title": "Input",
1054
1144
  "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
1145
  "dependencies": [
1056
- "dowel-ui@^0.30.0"
1146
+ "dowel-ui@^0.32.0"
1057
1147
  ],
1058
1148
  "registryDependencies": [],
1059
1149
  "files": [
@@ -1087,7 +1177,7 @@
1087
1177
  "title": "JsonViewer",
1088
1178
  "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
1179
  "dependencies": [
1090
- "dowel-ui@^0.30.0"
1180
+ "dowel-ui@^0.32.0"
1091
1181
  ],
1092
1182
  "registryDependencies": [
1093
1183
  "https://lacodda.github.io/dowel/r/json-rows.json"
@@ -1107,7 +1197,7 @@
1107
1197
  "title": "Kbd",
1108
1198
  "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
1199
  "dependencies": [
1110
- "dowel-ui@^0.30.0"
1200
+ "dowel-ui@^0.32.0"
1111
1201
  ],
1112
1202
  "registryDependencies": [],
1113
1203
  "files": [
@@ -1126,7 +1216,7 @@
1126
1216
  "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
1217
  "dependencies": [
1128
1218
  "class-variance-authority",
1129
- "dowel-ui@^0.30.0"
1219
+ "dowel-ui@^0.32.0"
1130
1220
  ],
1131
1221
  "registryDependencies": [],
1132
1222
  "files": [
@@ -1138,6 +1228,22 @@
1138
1228
  }
1139
1229
  ]
1140
1230
  },
1231
+ {
1232
+ "name": "layer",
1233
+ "type": "registry:ui",
1234
+ "title": "Layer",
1235
+ "description": "Which layer a popup opened inside an overlay lands on, so that a select in a dialog, a menu in a drawer and a date picker in a popover open above the thing that opened them.",
1236
+ "dependencies": [],
1237
+ "registryDependencies": [],
1238
+ "files": [
1239
+ {
1240
+ "path": "ui/layer.tsx",
1241
+ "target": "@ui/layer.tsx",
1242
+ "type": "registry:ui",
1243
+ "content": "import {\n createContext,\n useContext,\n useLayoutEffect,\n useMemo,\n useState,\n type ReactNode,\n type RefObject,\n} from 'react'\n\n/*\n * Layer.\n *\n * Which layer a popup opened inside an overlay lands on, so that a select in\n * a dialog, a menu in a drawer and a date picker in a popover open above the\n * thing that opened them.\n *\n * The stacking scale is a flat ladder - `--z-menu` 30, `--z-floating` 40,\n * `--z-modal` 60 - and every popup portals to the document body, so the ladder\n * alone decides who covers whom. It answers the common question right and has\n * nothing to say about the one that bites: a popup opened from inside an\n * overlay. A date picker in a dialog is a 40 beside a 60, so the month drew\n * under the dialog that asked for it; a menu in a drawer is a 30 beside a 60;\n * a select in a popover is a 30 beside a 40. Each reads as a control that does\n * nothing.\n *\n * Two other fixes were measured in kilna first, and both look right until the\n * screen is looked at. Portalling the popup INTO the overlay puts it in the\n * overlay's stacking context, where it does paint on top - but a dialog's body\n * scrolls, and a scroll box clips a popup inside it: the month came out with\n * one row of days. Raising the popup's own number works exactly once: it is\n * then above every overlay including the ones it is not inside, and the next\n * pairing arrives with no number left for it.\n *\n * So the popup stays at the body, where nothing clips it, and it is the LAYER\n * that travels. An overlay opens a host of its own on the body and raises the\n * popup rungs inside it; a popup opened anywhere within that overlay portals\n * into the host (`usePopupContainer`) and reads the raised value through\n * ordinary inheritance - its class still says `[z-index:var(--z-floating)]`\n * and has no idea this exists.\n *\n * **The floor is worked out by the stylesheet, not written in numbers.** The\n * donor in kilna kept a mirror of the ladder in TypeScript and a test that the\n * mirror had not drifted. Here the floor is `calc(var(--z-modal) + 1)`, read\n * from the theme where it is declared, so renumbering the ladder moves every\n * floor with it and there is no second copy to forget.\n *\n * **A layer inside a layer keeps climbing.** A popover opened in a dialog is a\n * layer of its own - a select inside it must clear the popover, not only the\n * dialog - so a nested host goes inside its parent's host and inherits the\n * raised rungs. Its floor is one above the larger of its own rung and its\n * parent's floor, which is right for both directions: a popover in a dialog\n * rides above the dialog's floor, and a dialog opened from inside a popover\n * gives its popups a floor above the modal rung rather than the popover's.\n *\n * **Two nodes, not one, and the reason is the cascade.** The floor is computed\n * from the rung the host inherits, and the host then redefines that same rung.\n * On one element that is a cycle - `--z-floating` defined through a floor that\n * is defined through `--z-floating` - and CSS answers a cycle by making both\n * invalid, which sends the z-index back to `auto`. So an outer frame works the\n * floor out from what it inherited, and the host inside it publishes it.\n * Neither node draws anything or makes a stacking context: they stay static,\n * so a popup inside is placed and measured exactly as it would be on the body.\n */\n\n/** The rungs of the stacking scale an overlay stands on. */\nexport type LayerRung = 'popup' | 'menu' | 'floating' | 'overlay' | 'modal' | 'palette'\n\n/** The rungs a popup reads its z-index from. Every one is raised, not only the\n * one a particular popup happens to read: a popover reads floating, a menu and\n * a select read menu, a tooltip reads popup - and for as long as the donor\n * lifted only floating, the select in a dialog stayed on the page's floor. */\nconst POPUP_RUNGS = ['--z-popup', '--z-menu', '--z-floating'] as const\n\ninterface Layer {\n readonly host: RefObject<HTMLElement | null>\n}\n\nconst LayerContext = createContext<Layer | null>(null)\n\n/** Where a popup opened here should portal to, or `undefined` when there is no\n * overlay around it and the body is right. A popup passes it as its portal's\n * `container`, after the caller's own. */\nexport function usePopupContainer(): RefObject<HTMLElement | null> | undefined {\n return useContext(LayerContext)?.host\n}\n\n/** The floor of a layer above `rung`, as the frame declares it: one above the\n * larger of the rung and the floor it is nested in. `--z-layer` is unset\n * outside every layer, and the fallback makes the rung win there. */\nexport function layerFloor(rung: LayerRung): string {\n return `calc(max(var(--z-${rung}), var(--z-layer, 0)) + 1)`\n}\n\nexport interface LayerProviderProps {\n /** The rung the overlay itself stands on: `modal` for a dialog or a drawer,\n * `palette` for the command palette, `floating` for a popover, `overlay`\n * for a product's own full-screen stage. */\n above: LayerRung\n /** Where the layer goes: the overlay's own portal node. Every overlay of\n * the set passes it, and it matters for the modal ones. A modal dialog\n * hides everything outside its portal from assistive technology - Base UI\n * marks the rest of the body `aria-hidden` - and a layer beside the portal\n * rather than in it was hidden with the page: the menu opened in a drawer\n * worked for a pointer and did not exist for a screen reader. Inside the\n * portal the layer counts as the overlay's own. Without it the layer goes\n * into the layer it was opened in, or onto the body. */\n mount?: RefObject<HTMLElement | null>\n children: ReactNode\n}\n\n/** Opens a raised host beside an overlay and offers it to every popup opened\n * inside. Put it inside the overlay's popup, around its content. */\nexport function LayerProvider({ above, mount, children }: LayerProviderProps) {\n const parent = useContext(LayerContext)\n\n // The nodes are made during the first render rather than in the effect. A\n // popup reads its `container` as it mounts, and a host that only appeared\n // on a second pass would let the first paint go to the body - the one frame\n // in which the bug is still there. A lazy state initialiser rather than a\n // ref filled in during render: it runs once, and nothing is read or written\n // through a ref while rendering. The pair never changes, so nothing ever\n // re-renders because of it.\n const [layer] = useState<Layer & { frame: HTMLElement | null }>(() => {\n if (typeof document === 'undefined') return { frame: null, host: { current: null } }\n const frame = document.createElement('div')\n const host = document.createElement('div')\n frame.append(host)\n return { frame, host: { current: host } }\n })\n\n // A layout effect, so the host is in the document before the browser paints\n // anything portalled into it. A child's effect runs before its parent's,\n // which is fine: the parent's host already exists from its render, and the\n // parent attaches the whole branch when its own effect runs.\n useLayoutEffect(() => {\n const outer = layer.frame\n const inner = layer.host.current\n if (outer === null || inner === null) return\n outer.setAttribute('data-dowel-layer', above)\n // Marked as a portal host, which is what it is - and what keeps a reader\n // able to reach it. A modal overlay hides every sibling of its popup from\n // assistive technology when it opens, inside its own portal as much as\n // on the body, and spares only the portal nodes nested in it. The layer\n // is empty at that moment - the select inside is opened later - so\n // without the mark the whole layer was hidden, and every popup opened\n // into it after: a menu that worked for a pointer and was not there for\n // a screen reader.\n outer.setAttribute('data-base-ui-portal', '')\n outer.style.setProperty('--z-layer-next', layerFloor(above))\n inner.style.setProperty('--z-layer', 'var(--z-layer-next)')\n for (const rung of POPUP_RUNGS) inner.style.setProperty(rung, 'var(--z-layer-next)')\n ;(mount?.current ?? parent?.host.current ?? document.body).append(outer)\n return () => outer.remove()\n }, [above, layer, mount, parent])\n\n const value = useMemo<Layer>(() => ({ host: layer.host }), [layer])\n return <LayerContext.Provider value={value}>{children}</LayerContext.Provider>\n}\n"
1244
+ }
1245
+ ]
1246
+ },
1141
1247
  {
1142
1248
  "name": "line-chart",
1143
1249
  "type": "registry:ui",
@@ -1145,7 +1251,7 @@
1145
1251
  "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
1252
  "dependencies": [
1147
1253
  "class-variance-authority",
1148
- "dowel-ui@^0.30.0"
1254
+ "dowel-ui@^0.32.0"
1149
1255
  ],
1150
1256
  "registryDependencies": [
1151
1257
  "https://lacodda.github.io/dowel/r/line-scale.json"
@@ -1181,7 +1287,7 @@
1181
1287
  "title": "MarkedText",
1182
1288
  "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
1289
  "dependencies": [
1184
- "dowel-ui@^0.30.0"
1290
+ "dowel-ui@^0.32.0"
1185
1291
  ],
1186
1292
  "registryDependencies": [],
1187
1293
  "files": [
@@ -1201,15 +1307,17 @@
1201
1307
  "dependencies": [
1202
1308
  "@base-ui/react",
1203
1309
  "class-variance-authority",
1204
- "dowel-ui@^0.30.0"
1310
+ "dowel-ui@^0.32.0"
1311
+ ],
1312
+ "registryDependencies": [
1313
+ "https://lacodda.github.io/dowel/r/layer.json"
1205
1314
  ],
1206
- "registryDependencies": [],
1207
1315
  "files": [
1208
1316
  {
1209
1317
  "path": "ui/menu.tsx",
1210
1318
  "target": "@ui/menu.tsx",
1211
1319
  "type": "registry:ui",
1212
- "content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n [\n 'min-w-40 rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'min-w-32',\n md: 'min-w-40',\n lg: 'min-w-56',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n [\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n default: 'text-dim data-[highlighted]:text-text',\n danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof menuPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the menu\n * from being clipped by a row with `overflow: hidden` - which is where most\n * hand-written ones go to die. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: MenuPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
1320
+ "content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n [\n 'min-w-40 rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'min-w-32',\n md: 'min-w-40',\n lg: 'min-w-56',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n [\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg:not([class*=size-])]:size-3.5 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n default: 'text-dim data-[highlighted]:text-text',\n danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof menuPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: MenuPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\nexport interface MenuCheckboxItemProps\n extends Base.CheckboxItem.Props,\n VariantProps<typeof menuItemVariants> {}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything.\n *\n * Typed from `CheckboxItem` rather than `Item`: it was declared with the plain\n * item's props while rendering a checkbox, so `checked` and `onCheckedChange` -\n * the two things it exists for - were rejected by the compiler. Nothing had\n * called it until kilna's column picker did. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuCheckboxItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
1213
1321
  }
1214
1322
  ]
1215
1323
  },
@@ -1221,7 +1329,7 @@
1221
1329
  "dependencies": [
1222
1330
  "@base-ui/react",
1223
1331
  "class-variance-authority",
1224
- "dowel-ui@^0.30.0"
1332
+ "dowel-ui@^0.32.0"
1225
1333
  ],
1226
1334
  "registryDependencies": [],
1227
1335
  "files": [
@@ -1229,7 +1337,7 @@
1229
1337
  "path": "ui/nav-rail.tsx",
1230
1338
  "target": "@ui/nav-rail.tsx",
1231
1339
  "type": "registry:ui",
1232
- "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * NavRail.\n *\n * The product's own destinations: the screens it has, drawn as a column down\n * the left, a row of tabs in a header, or a bar along the bottom of a phone.\n * One list, three shapes, because they are the same list - and when they were\n * three components in two products they drew the current entry three\n * different ways.\n *\n * The entries are the product's links, not this component's. `render` takes\n * an item and answers with the element to draw it as - a router's `NavLink`,\n * a plain `<a>`, whatever the product navigates with - and the rail puts the\n * clothes, the icon and the `aria-current` on it. That keeps the router out\n * of the registry entirely: a set that imported `react-router` would install\n * one in a product that had chosen another, and there is no version of that\n * which is the design system's business.\n *\n * `NavGroup` is the small uppercase caption between runs of entries, and\n * `NavSpacer` pushes what follows to the far end - the settings, the theme\n * switch, the profile, which every rail in the line keeps at its foot.\n */\n\nexport const navRailVariants = cva('flex', {\n variants: {\n /*\n * Which of the three shapes this is.\n *\n * Not a guess from the width: a rail that changed shape when its\n * container narrowed would change shape in the middle of a drag.\n */\n layout: {\n /* Down the left, the desktop shape. `h-full` so the rail runs the\n * height of the window and its foot sits at the bottom because the nav\n * reaches it, not because the content does. */\n column: 'h-full flex-col gap-0.5 overflow-y-auto border-r border-line px-2.5 pt-2.5 pb-3',\n /* A row of tabs, in a header beside the product's name. */\n row: 'flex-row items-center gap-1',\n /* Along the bottom, where a thumb already is. The safe-area inset is\n * the phone's home indicator: without it the last row of entries sits\n * under it with no way to scroll out. */\n bar: 'flex-row border-t border-line bg-raise pb-[env(safe-area-inset-bottom)]',\n },\n },\n defaultVariants: { layout: 'column' },\n})\n\nexport interface NavRailItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A count, a dot, a version chip - whatever sits at the end of the row.\n * Drawn only in `column`, where there is width for it. */\n end?: ReactNode\n /** A destination that exists on the roadmap but not in the build. It is\n * drawn greyed and is not a link: the point is to say the screen is coming,\n * which an entry that navigates nowhere would say by doing nothing. */\n soon?: boolean\n}\n\nexport interface NavRailProps\n extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'>,\n VariantProps<typeof navRailVariants> {\n /** What this set of destinations is called. Not drawn - it names the\n * landmark, so a reader meeting two navs on one page can tell which is\n * which. A product with a rail AND a bottom bar has exactly that. */\n label: string\n items: readonly NavRailItem[]\n /** Which destination is the open screen. */\n activeId?: string\n /** The element an entry is drawn as - `render={(item) => <NavLink to={…} />}`. */\n render?: (item: NavRailItem) => useRender.RenderProp\n /** Pressed, whatever the entry is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function NavRail({\n layout,\n label,\n items,\n activeId,\n render,\n onSelect,\n className,\n ...props\n}: NavRailProps) {\n return (\n <nav aria-label={label} className={cn(navRailVariants({ layout }), className)} {...props}>\n {items.map((item) => (\n <NavRailEntry\n key={item.id}\n item={item}\n layout={layout ?? 'column'}\n active={item.id === activeId}\n render={item.soon === true ? undefined : render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\n/** The clothes of one entry, by shape.\n *\n * `target-min` on the two narrow shapes rather than on all three: a column\n * entry is already taller than the floor, and the utility establishes a\n * containing block, which is not free on every row of a list. */\nconst entryClasses = {\n column:\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm no-underline transition-colors',\n /* `flex` here for the same reason as the other two, and it was missing: a\n * tab is an icon beside a word, and without it the entry is a block, the\n * icon and the label stack, and the header grows to twice its height. The\n * class-list tests did not see it - they read the string, not the layout -\n * and the stand did, in the first screenshot. */\n row: 'target-min flex items-center gap-2 rounded-md px-2.5 py-1 text-sm no-underline transition-colors',\n bar: 'target-min flex min-h-14 min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 py-2 text-2xs no-underline transition-colors',\n} as const\n\nfunction NavRailEntry({\n item,\n layout,\n active,\n render,\n onSelect,\n}: {\n item: NavRailItem\n layout: NonNullable<NavRailProps['layout']>\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n const soon = item.soon === true\n\n return useRender({\n render,\n // A destination is a link when the product gives it one and a button when\n // it does not; one that is not built yet is neither, and `span` says so -\n // a disabled button is still in the tab order on some browsers, and\n // tabbing onto a screen that does not exist is a dead end.\n defaultTagName: soon ? 'span' : 'button',\n props: {\n ...(render === undefined && !soon ? { type: 'button' } : {}),\n // Named as the current page, which is how a reader learns which of six\n // identical links is where they stand. Colour alone says it to nobody\n // who cannot see it.\n 'aria-current': active ? 'page' : undefined,\n ...(soon ? { 'aria-disabled': true } : {}),\n onClick: soon ? undefined : () => onSelect?.(item.id),\n className: cn(\n entryClasses[layout],\n '[&_svg]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n soon\n ? 'cursor-default text-faint'\n : cn(\n 'cursor-pointer text-dim hover:bg-soft hover:text-text',\n // The bottom bar tints the entry itself rather than filling the\n // cell: a filled cell in a four-across bar reads as a pressed\n // button, and it is a location, not an action.\n active &&\n (layout === 'bar'\n ? 'text-accent-2 hover:bg-transparent'\n : 'bg-accent-soft text-text [&_svg]:text-accent'),\n ),\n ),\n children: (\n <>\n {item.icon === undefined ? null : (\n <span aria-hidden className=\"contents\">\n {item.icon}\n </span>\n )}\n {/* Truncated, in one line. A rail is a fixed-width column, and a\n label that wraps onto a second line makes the row taller than\n every other - which in a two-language product happens to one\n entry and not the rest. `min-w-0` because a flex child refuses\n to shrink below its content without it. */}\n <span className={cn('min-w-0 truncate', layout === 'bar' ? 'w-full text-center' : 'flex-1')}>\n {item.label}\n </span>\n {item.end === undefined || layout !== 'column' ? null : (\n <span className=\"ml-auto shrink-0 text-2xs text-faint\">{item.end}</span>\n )}\n </>\n ),\n },\n })\n}\n\n/** The caption over a run of entries: Library, Team, Admin.\n *\n * A `div` rather than a heading: a rail's groups are not the page's outline,\n * and six `h3`s inside a nav put six entries into a reader's document map\n * that lead nowhere. */\nexport function NavGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'px-2.5 pt-3 pb-1 text-2xs font-medium uppercase tracking-caption text-faint',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Pushes everything after it to the far end of the rail. */\nexport function NavSpacer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-auto flex flex-col gap-0.5', className)} {...props} />\n}\n"
1340
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * NavRail.\n *\n * The product's own destinations: the screens it has, drawn as a column down\n * the left, a row of tabs in a header, or a bar along the bottom of a phone.\n * One list, three shapes, because they are the same list - and when they were\n * three components in two products they drew the current entry three\n * different ways.\n *\n * The entries are the product's links, not this component's. `render` takes\n * an item and answers with the element to draw it as - a router's `NavLink`,\n * a plain `<a>`, whatever the product navigates with - and the rail puts the\n * clothes, the icon and the `aria-current` on it. That keeps the router out\n * of the registry entirely: a set that imported `react-router` would install\n * one in a product that had chosen another, and there is no version of that\n * which is the design system's business.\n *\n * `NavGroup` is the small uppercase caption between runs of entries, and\n * `NavSpacer` pushes what follows to the far end - the settings, the theme\n * switch, the profile, which every rail in the line keeps at its foot.\n */\n\nexport const navRailVariants = cva('flex', {\n variants: {\n /*\n * Which of the three shapes this is.\n *\n * Not a guess from the width: a rail that changed shape when its\n * container narrowed would change shape in the middle of a drag.\n */\n layout: {\n /* Down the left, the desktop shape. `h-full` so the rail runs the\n * height of the window and its foot sits at the bottom because the nav\n * reaches it, not because the content does. */\n column: 'h-full flex-col gap-0.5 overflow-y-auto border-r border-line px-2.5 pt-2.5 pb-3',\n /* A row of tabs, in a header beside the product's name. */\n row: 'flex-row items-center gap-1',\n /* Along the bottom, where a thumb already is. The safe-area inset is\n * the phone's home indicator: without it the last row of entries sits\n * under it with no way to scroll out. */\n bar: 'flex-row border-t border-line bg-raise pb-[env(safe-area-inset-bottom)]',\n },\n },\n defaultVariants: { layout: 'column' },\n})\n\nexport interface NavRailItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A count, a dot, a version chip - whatever sits at the end of the row.\n * Drawn only in `column`, where there is width for it. */\n end?: ReactNode\n /** A destination that exists on the roadmap but not in the build. It is\n * drawn greyed and is not a link: the point is to say the screen is coming,\n * which an entry that navigates nowhere would say by doing nothing. */\n soon?: boolean\n}\n\nexport interface NavRailProps\n extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'>,\n VariantProps<typeof navRailVariants> {\n /** What this set of destinations is called. Not drawn - it names the\n * landmark, so a reader meeting two navs on one page can tell which is\n * which. A product with a rail AND a bottom bar has exactly that. */\n label: string\n items: readonly NavRailItem[]\n /** Which destination is the open screen. */\n activeId?: string\n /** The element an entry is drawn as - `render={(item) => <NavLink to={…} />}`. */\n render?: (item: NavRailItem) => useRender.RenderProp\n /** Pressed, whatever the entry is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function NavRail({\n layout,\n label,\n items,\n activeId,\n render,\n onSelect,\n className,\n ...props\n}: NavRailProps) {\n return (\n <nav aria-label={label} className={cn(navRailVariants({ layout }), className)} {...props}>\n {items.map((item) => (\n <NavRailEntry\n key={item.id}\n item={item}\n layout={layout ?? 'column'}\n active={item.id === activeId}\n render={item.soon === true ? undefined : render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\n/** The clothes of one entry, by shape.\n *\n * `target-min` on the two narrow shapes rather than on all three: a column\n * entry is already taller than the floor, and the utility establishes a\n * containing block, which is not free on every row of a list. */\nconst entryClasses = {\n column:\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm no-underline transition-colors',\n /* `flex` here for the same reason as the other two, and it was missing: a\n * tab is an icon beside a word, and without it the entry is a block, the\n * icon and the label stack, and the header grows to twice its height. The\n * class-list tests did not see it - they read the string, not the layout -\n * and the stand did, in the first screenshot. */\n row: 'target-min flex items-center gap-2 rounded-md px-2.5 py-1 text-sm no-underline transition-colors',\n bar: 'target-min flex min-h-14 min-w-0 flex-1 flex-col items-center justify-center gap-1 px-1 py-2 text-2xs no-underline transition-colors',\n} as const\n\nfunction NavRailEntry({\n item,\n layout,\n active,\n render,\n onSelect,\n}: {\n item: NavRailItem\n layout: NonNullable<NavRailProps['layout']>\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n const soon = item.soon === true\n\n return useRender({\n render,\n // A destination is a link when the product gives it one and a button when\n // it does not; one that is not built yet is neither, and `span` says so -\n // a disabled button is still in the tab order on some browsers, and\n // tabbing onto a screen that does not exist is a dead end.\n defaultTagName: soon ? 'span' : 'button',\n props: {\n ...(render === undefined && !soon ? { type: 'button' } : {}),\n // Named as the current page, which is how a reader learns which of six\n // identical links is where they stand. Colour alone says it to nobody\n // who cannot see it.\n 'aria-current': active ? 'page' : undefined,\n ...(soon ? { 'aria-disabled': true } : {}),\n onClick: soon ? undefined : () => onSelect?.(item.id),\n className: cn(\n entryClasses[layout],\n '[&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n soon\n ? 'cursor-default text-faint'\n : cn(\n 'cursor-pointer text-dim hover:bg-soft hover:text-text',\n // The bottom bar tints the entry itself rather than filling the\n // cell: a filled cell in a four-across bar reads as a pressed\n // button, and it is a location, not an action.\n active &&\n (layout === 'bar'\n ? 'text-accent-2 hover:bg-transparent'\n : 'bg-accent-soft text-text [&_svg]:text-accent'),\n ),\n ),\n children: (\n <>\n {item.icon === undefined ? null : (\n <span aria-hidden className=\"contents\">\n {item.icon}\n </span>\n )}\n {/* Truncated, in one line. A rail is a fixed-width column, and a\n label that wraps onto a second line makes the row taller than\n every other - which in a two-language product happens to one\n entry and not the rest. `min-w-0` because a flex child refuses\n to shrink below its content without it. */}\n <span className={cn('min-w-0 truncate', layout === 'bar' ? 'w-full text-center' : 'flex-1')}>\n {item.label}\n </span>\n {item.end === undefined || layout !== 'column' ? null : (\n <span className=\"ml-auto shrink-0 text-2xs text-faint\">{item.end}</span>\n )}\n </>\n ),\n },\n })\n}\n\n/** The caption over a run of entries: Library, Team, Admin.\n *\n * A `div` rather than a heading: a rail's groups are not the page's outline,\n * and six `h3`s inside a nav put six entries into a reader's document map\n * that lead nowhere. */\nexport function NavGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return (\n <div\n className={cn(\n 'px-2.5 pt-3 pb-1 text-2xs font-medium uppercase tracking-caption text-faint',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** Pushes everything after it to the far end of the rail. */\nexport function NavSpacer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n return <div className={cn('mt-auto flex flex-col gap-0.5', className)} {...props} />\n}\n"
1233
1341
  }
1234
1342
  ]
1235
1343
  },
@@ -1239,7 +1347,7 @@
1239
1347
  "title": "NotificationBell",
1240
1348
  "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
1349
  "dependencies": [
1242
- "dowel-ui@^0.30.0"
1350
+ "dowel-ui@^0.32.0"
1243
1351
  ],
1244
1352
  "registryDependencies": [
1245
1353
  "https://lacodda.github.io/dowel/r/button.json",
@@ -1261,7 +1369,7 @@
1261
1369
  "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
1370
  "dependencies": [
1263
1371
  "@base-ui/react",
1264
- "dowel-ui@^0.30.0"
1372
+ "dowel-ui@^0.32.0"
1265
1373
  ],
1266
1374
  "registryDependencies": [
1267
1375
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1271,7 +1379,7 @@
1271
1379
  "path": "ui/number-field.tsx",
1272
1380
  "target": "@ui/number-field.tsx",
1273
1381
  "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"
1382
+ "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
1383
  }
1276
1384
  ]
1277
1385
  },
@@ -1281,7 +1389,7 @@
1281
1389
  "title": "NumberFormat",
1282
1390
  "description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
1283
1391
  "dependencies": [
1284
- "dowel-ui@^0.30.0"
1392
+ "dowel-ui@^0.32.0"
1285
1393
  ],
1286
1394
  "registryDependencies": [],
1287
1395
  "files": [
@@ -1289,7 +1397,7 @@
1289
1397
  "path": "ui/number-format.tsx",
1290
1398
  "target": "@ui/number-format.tsx",
1291
1399
  "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"
1400
+ "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
1401
  }
1294
1402
  ]
1295
1403
  },
@@ -1300,7 +1408,7 @@
1300
1408
  "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
1409
  "dependencies": [
1302
1410
  "class-variance-authority",
1303
- "dowel-ui@^0.30.0"
1411
+ "dowel-ui@^0.32.0"
1304
1412
  ],
1305
1413
  "registryDependencies": [],
1306
1414
  "files": [
@@ -1318,7 +1426,7 @@
1318
1426
  "title": "PageSize",
1319
1427
  "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
1428
  "dependencies": [
1321
- "dowel-ui@^0.30.0"
1429
+ "dowel-ui@^0.32.0"
1322
1430
  ],
1323
1431
  "registryDependencies": [
1324
1432
  "https://lacodda.github.io/dowel/r/select.json"
@@ -1338,7 +1446,7 @@
1338
1446
  "title": "Pagination",
1339
1447
  "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
1448
  "dependencies": [
1341
- "dowel-ui@^0.30.0"
1449
+ "dowel-ui@^0.32.0"
1342
1450
  ],
1343
1451
  "registryDependencies": [
1344
1452
  "https://lacodda.github.io/dowel/r/button.json"
@@ -1359,7 +1467,7 @@
1359
1467
  "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
1468
  "dependencies": [
1361
1469
  "class-variance-authority",
1362
- "dowel-ui@^0.30.0"
1470
+ "dowel-ui@^0.32.0"
1363
1471
  ],
1364
1472
  "registryDependencies": [],
1365
1473
  "files": [
@@ -1377,7 +1485,7 @@
1377
1485
  "title": "PasswordField",
1378
1486
  "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
1487
  "dependencies": [
1380
- "dowel-ui@^0.30.0"
1488
+ "dowel-ui@^0.32.0"
1381
1489
  ],
1382
1490
  "registryDependencies": [
1383
1491
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1399,15 +1507,17 @@
1399
1507
  "dependencies": [
1400
1508
  "@base-ui/react",
1401
1509
  "class-variance-authority",
1402
- "dowel-ui@^0.30.0"
1510
+ "dowel-ui@^0.32.0"
1511
+ ],
1512
+ "registryDependencies": [
1513
+ "https://lacodda.github.io/dowel/r/layer.json"
1403
1514
  ],
1404
- "registryDependencies": [],
1405
1515
  "files": [
1406
1516
  {
1407
1517
  "path": "ui/popover.tsx",
1408
1518
  "target": "@ui/popover.tsx",
1409
1519
  "type": "registry:ui",
1410
- "content": "import { Popover as Base } from '@base-ui/react/popover'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Popover.\n *\n * A panel anchored to the thing that opened it, for content that belongs\n * beside a control rather than in the middle of the screen: a filter, a small\n * form, a menu of settings that is not a menu.\n *\n * Where Dialog is centred and covers the page, this one is placed, and the\n * placing is the part that is easy to get wrong. It goes through a Positioner\n * between the Portal and the Popup - Base UI measures the trigger, measures\n * the popup, and flips or shifts it when the preferred side does not fit. So\n * `side` and `align` are a preference, not an instruction, and a popover near\n * the bottom of the window will come out above its trigger. That is the\n * behaviour worth having; a popover that stays where it was told is a popover\n * half off the screen.\n *\n * `sideOffset` is a distance from the anchor and belongs to the Positioner,\n * not the Popup, so it is taken here and passed inward. The Arrow is optional\n * and sits inside the Popup, where Base UI rotates it to whichever side the\n * popup actually landed on.\n *\n * It is not modal. The page underneath stays live, and focus is not trapped -\n * which is right for a panel beside a control and wrong for a decision.\n */\n\nexport const popoverPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(16rem,calc(100vw-2rem))]',\n md: 'w-[min(20rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PopoverTrigger`. */\nexport const Popover = Base.Root\n\n/** What opens it, and what the popup is measured against. */\nexport const PopoverTrigger = Base.Trigger\n\n/** What closes it, for a button inside the panel. */\nexport const PopoverClose = Base.Close\n\nexport interface PopoverPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof popoverPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned, so it is not clipped by an ancestor\n * with `overflow: hidden` - which is where most anchored popups go to die.\n *\n * `side` and `align` are passed straight through rather than defaulted here:\n * Base UI's own defaults are already bottom and centre, and writing them out\n * again would be one more place for the two to disagree. */\nexport function PopoverPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PopoverPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(popoverPopupVariants({ size }), className)} {...props}>\n {arrow ? <PopoverArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the popup landed on, which is why the rotation is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PopoverArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * popover with one is named for a screen reader without anyone arranging it. */\nexport function PopoverTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-sm font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function PopoverDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
1520
+ "content": "import { useRef } from 'react'\nimport { Popover as Base } from '@base-ui/react/popover'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { LayerProvider, usePopupContainer } from './layer'\n\n/*\n * Popover.\n *\n * A panel anchored to the thing that opened it, for content that belongs\n * beside a control rather than in the middle of the screen: a filter, a small\n * form, a menu of settings that is not a menu.\n *\n * Where Dialog is centred and covers the page, this one is placed, and the\n * placing is the part that is easy to get wrong. It goes through a Positioner\n * between the Portal and the Popup - Base UI measures the trigger, measures\n * the popup, and flips or shifts it when the preferred side does not fit. So\n * `side` and `align` are a preference, not an instruction, and a popover near\n * the bottom of the window will come out above its trigger. That is the\n * behaviour worth having; a popover that stays where it was told is a popover\n * half off the screen.\n *\n * `sideOffset` is a distance from the anchor and belongs to the Positioner,\n * not the Popup, so it is taken here and passed inward. The Arrow is optional\n * and sits inside the Popup, where Base UI rotates it to whichever side the\n * popup actually landed on.\n *\n * It is not modal. The page underneath stays live, and focus is not trapped -\n * which is right for a panel beside a control and wrong for a decision.\n */\n\nexport const popoverPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(16rem,calc(100vw-2rem))]',\n md: 'w-[min(20rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PopoverTrigger`. */\nexport const Popover = Base.Root\n\n/** What opens it, and what the popup is measured against. */\nexport const PopoverTrigger = Base.Trigger\n\n/** What closes it, for a button inside the panel. */\nexport const PopoverClose = Base.Close\n\nexport interface PopoverPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof popoverPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned, so it is not clipped by an ancestor\n * with `overflow: hidden` - which is where most anchored popups go to die.\n *\n * `side` and `align` are passed straight through rather than defaulted here:\n * Base UI's own defaults are already bottom and centre, and writing them out\n * again would be one more place for the two to disagree. */\nexport function PopoverPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PopoverPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(popoverPopupVariants({ size }), className)} {...props}>\n {arrow ? <PopoverArrow /> : null}\n {/* A popover is a layer of its own: a select or a date picker inside it\n * opens above it, not under it. */}\n <LayerProvider above=\"floating\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the popup landed on, which is why the rotation is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PopoverArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * popover with one is named for a screen reader without anyone arranging it. */\nexport function PopoverTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-sm font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function PopoverDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
1411
1521
  }
1412
1522
  ]
1413
1523
  },
@@ -1419,15 +1529,56 @@
1419
1529
  "dependencies": [
1420
1530
  "@base-ui/react",
1421
1531
  "class-variance-authority",
1422
- "dowel-ui@^0.30.0"
1532
+ "dowel-ui@^0.32.0"
1533
+ ],
1534
+ "registryDependencies": [
1535
+ "https://lacodda.github.io/dowel/r/layer.json"
1423
1536
  ],
1424
- "registryDependencies": [],
1425
1537
  "files": [
1426
1538
  {
1427
1539
  "path": "ui/preview-card.tsx",
1428
1540
  "target": "@ui/preview-card.tsx",
1429
1541
  "type": "registry:ui",
1430
- "content": "import { PreviewCard as Base } from '@base-ui/react/preview-card'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * PreviewCard.\n *\n * The card that appears when a link is hovered: who the author is, what the\n * issue says, what is behind the URL. Rich content - an avatar, a few lines,\n * a figure or two - rather than the phrase a Tooltip holds.\n *\n * Positioned the same way a Popover is, through a Positioner between the\n * Portal and the Popup, so it flips and shifts to stay on screen. What differs\n * is how it opens: hovering the trigger, after a delay, and it stays open\n * while the pointer travels from the link to the card. That last part is the\n * whole trick - a card that vanishes when the pointer leaves the link cannot\n * be read, let alone clicked into.\n *\n * Same warning as Tooltip, and for the same reason. Base UI treats this as a\n * visual enhancement for sighted mouse and keyboard users: it is not reachable\n * on a touch screen and not announced by a screen reader. So NOTHING IN THE\n * CARD MAY BE THE ONLY PLACE IT APPEARS. Everything in it has to also be on\n * the page the link goes to - the card is a shortcut for people who can see\n * it, never the delivery mechanism for the information itself.\n *\n * If the content has to be reachable by everyone, this is the wrong component;\n * a Popover opened from a real button is the right one.\n */\n\nexport const previewCardPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-sm text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the link.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(18rem,calc(100vw-2rem))]',\n md: 'w-[min(22rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PreviewCardTrigger`. */\nexport const PreviewCard = Base.Root\n\n/** The link the card previews. Usually rendered as the anchor itself, so it\n * stays a real link: it navigates, it opens in a new tab, and a screen reader\n * announces it as one - which is what the card cannot do. Takes `delay`. */\nexport const PreviewCardTrigger = Base.Trigger\n\nexport interface PreviewCardPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof previewCardPopupVariants> {\n /** Preferred side of the link. Base UI flips it when it does not fit, and\n * defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the link, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the link. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The card. Portalled and positioned, so it is not clipped by the paragraph\n * the link sits in. */\nexport function PreviewCardPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PreviewCardPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(previewCardPopupVariants({ size }), className)} {...props}>\n {arrow ? <PreviewCardArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the link. Base UI rotates it to whatever side\n * the card landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PreviewCardArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1542
+ "content": "import { PreviewCard as Base } from '@base-ui/react/preview-card'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * PreviewCard.\n *\n * The card that appears when a link is hovered: who the author is, what the\n * issue says, what is behind the URL. Rich content - an avatar, a few lines,\n * a figure or two - rather than the phrase a Tooltip holds.\n *\n * Positioned the same way a Popover is, through a Positioner between the\n * Portal and the Popup, so it flips and shifts to stay on screen. What differs\n * is how it opens: hovering the trigger, after a delay, and it stays open\n * while the pointer travels from the link to the card. That last part is the\n * whole trick - a card that vanishes when the pointer leaves the link cannot\n * be read, let alone clicked into.\n *\n * Same warning as Tooltip, and for the same reason. Base UI treats this as a\n * visual enhancement for sighted mouse and keyboard users: it is not reachable\n * on a touch screen and not announced by a screen reader. So NOTHING IN THE\n * CARD MAY BE THE ONLY PLACE IT APPEARS. Everything in it has to also be on\n * the page the link goes to - the card is a shortcut for people who can see\n * it, never the delivery mechanism for the information itself.\n *\n * If the content has to be reachable by everyone, this is the wrong component;\n * a Popover opened from a real button is the right one.\n */\n\nexport const previewCardPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-sm text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the link.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(18rem,calc(100vw-2rem))]',\n md: 'w-[min(22rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PreviewCardTrigger`. */\nexport const PreviewCard = Base.Root\n\n/** The link the card previews. Usually rendered as the anchor itself, so it\n * stays a real link: it navigates, it opens in a new tab, and a screen reader\n * announces it as one - which is what the card cannot do. Takes `delay`. */\nexport const PreviewCardTrigger = Base.Trigger\n\nexport interface PreviewCardPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof previewCardPopupVariants> {\n /** Preferred side of the link. Base UI flips it when it does not fit, and\n * defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the link, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the link. */\n arrow?: boolean\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The card. Portalled and positioned, so it is not clipped by the paragraph\n * the link sits in. */\nexport function PreviewCardPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PreviewCardPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(previewCardPopupVariants({ size }), className)} {...props}>\n {arrow ? <PreviewCardArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the link. Base UI rotates it to whatever side\n * the card landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PreviewCardArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1543
+ }
1544
+ ]
1545
+ },
1546
+ {
1547
+ "name": "product-mark",
1548
+ "type": "registry:ui",
1549
+ "title": "ProductMark",
1550
+ "description": "The product-mark primitive.",
1551
+ "dependencies": [
1552
+ "dowel-ui@^0.32.0"
1553
+ ],
1554
+ "registryDependencies": [],
1555
+ "files": [
1556
+ {
1557
+ "path": "ui/product-mark.tsx",
1558
+ "target": "@ui/product-mark.tsx",
1559
+ "type": "registry:ui",
1560
+ "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"
1561
+ }
1562
+ ]
1563
+ },
1564
+ {
1565
+ "name": "product-switcher",
1566
+ "type": "registry:ui",
1567
+ "title": "ProductSwitcher",
1568
+ "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.",
1569
+ "dependencies": [
1570
+ "dowel-ui@^0.32.0"
1571
+ ],
1572
+ "registryDependencies": [
1573
+ "https://lacodda.github.io/dowel/r/menu.json",
1574
+ "https://lacodda.github.io/dowel/r/product-mark.json"
1575
+ ],
1576
+ "files": [
1577
+ {
1578
+ "path": "ui/product-switcher.tsx",
1579
+ "target": "@ui/product-switcher.tsx",
1580
+ "type": "registry:ui",
1581
+ "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"
1431
1582
  }
1432
1583
  ]
1433
1584
  },
@@ -1439,7 +1590,7 @@
1439
1590
  "dependencies": [
1440
1591
  "@base-ui/react",
1441
1592
  "class-variance-authority",
1442
- "dowel-ui@^0.30.0"
1593
+ "dowel-ui@^0.32.0"
1443
1594
  ],
1444
1595
  "registryDependencies": [],
1445
1596
  "files": [
@@ -1478,7 +1629,7 @@
1478
1629
  "dependencies": [
1479
1630
  "@base-ui/react",
1480
1631
  "class-variance-authority",
1481
- "dowel-ui@^0.30.0"
1632
+ "dowel-ui@^0.32.0"
1482
1633
  ],
1483
1634
  "registryDependencies": [],
1484
1635
  "files": [
@@ -1496,7 +1647,7 @@
1496
1647
  "title": "RatingScale",
1497
1648
  "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
1649
  "dependencies": [
1499
- "dowel-ui@^0.30.0"
1650
+ "dowel-ui@^0.32.0"
1500
1651
  ],
1501
1652
  "registryDependencies": [],
1502
1653
  "files": [
@@ -1514,7 +1665,7 @@
1514
1665
  "title": "RelativeTime",
1515
1666
  "description": "The relative-time primitive.",
1516
1667
  "dependencies": [
1517
- "dowel-ui@^0.30.0"
1668
+ "dowel-ui@^0.32.0"
1518
1669
  ],
1519
1670
  "registryDependencies": [],
1520
1671
  "files": [
@@ -1522,7 +1673,7 @@
1522
1673
  "path": "ui/relative-time.tsx",
1523
1674
  "target": "@ui/relative-time.tsx",
1524
1675
  "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"
1676
+ "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
1677
  }
1527
1678
  ]
1528
1679
  },
@@ -1532,7 +1683,7 @@
1532
1683
  "title": "ReorderableList",
1533
1684
  "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
1685
  "dependencies": [
1535
- "dowel-ui@^0.30.0"
1686
+ "dowel-ui@^0.32.0"
1536
1687
  ],
1537
1688
  "registryDependencies": [],
1538
1689
  "files": [
@@ -1540,7 +1691,7 @@
1540
1691
  "path": "ui/reorderable-list.tsx",
1541
1692
  "target": "@ui/reorderable-list.tsx",
1542
1693
  "type": "registry:ui",
1543
- "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drag a row of a vertical list up or down to put it somewhere else.\n *\n * The columns in a column picker, the stops of a dial, the roles of a\n * profile. A hook and a grip rather than a list component: the rows are\n * already something else's - a menu's items, a form's fields - and a\n * component wrapping them would have to reproduce whatever that something\n * else does.\n *\n * Pointer events, not HTML5 drag-and-drop, for the reason given at\n * ColumnResizeHandle: a desktop shell that takes file drops never lets a\n * `dragstart` reach the page. The grip takes pointer capture on pointerdown,\n * so the rows underneath never see the drag and a menu's own highlighting\n * does not flicker down the list as the pointer crosses it.\n *\n * Nothing moves until the pointer is let go. Live reordering looks better\n * for a second and costs a write per crossed row - to a profile, that is a\n * request per row - and the row being dragged has already been picked up,\n * so the reader is watching the line that says where it lands. The hook\n * reports that line as `slot`, and the caller draws it.\n *\n * The keyboard's way is Alt with an arrow on the focused row. Plain arrows\n * are how a list is walked, and taking them for moving would leave no way\n * to walk it; the modifier is the one screen readers and editors already\n * use for \"move this line\".\n */\n\nexport interface ReorderOptions<K extends string> {\n /** The ids in their current order. Every row carries its id in\n * `data-reorder-id`, which `rowProps` sets. */\n order: readonly K[]\n /** Put `id` at index `to` of the resulting list. Called once per drop or\n * per keypress, never during a drag. */\n onMove: (id: K, to: number) => void\n disabled?: boolean\n}\n\nexport interface Reorder<K extends string> {\n /** Spread on the element holding the rows: the rows are found under it.\n * A callback ref rather than a ref object, so that nothing ref-shaped is\n * handed back and the compiler does not take the whole result for one. */\n listProps: { ref: (element: HTMLElement | null) => void }\n /** The row being dragged, or null. */\n dragging: K | null\n /** Where a drop would go, as an insertion point: between the rows at\n * `slot - 1` and `slot`, in the order as it is. Null while nothing is\n * dragged, or while the drop would change nothing. */\n slot: number | null\n /** The insertion point's distance from the top of the list element, in\n * pixels, for drawing the line. */\n slotOffset: number | null\n /** Spread on the grip: the pointer path. */\n gripProps: (id: K) => (HTMLAttributes<HTMLElement>)\n /** Spread on the row: the id and the keyboard path. The `ref` is a\n * callback that returns its cleanup, as React 19 allows; a host that merges\n * refs and drops the cleanup leaves a listener on a node that is gone,\n * which is harmless. */\n rowProps: (id: K) => {\n 'data-reorder-id': K\n ref: (row: HTMLElement | null) => void | (() => void)\n }\n}\n\nexport function useReorder<K extends string>({ order, onMove, disabled }: ReorderOptions<K>): Reorder<K> {\n const listRef = useRef<HTMLElement | null>(null)\n const [dragging, setDragging] = useState<K | null>(null)\n // The slot and where to draw it, settled together in the move handler: the\n // boxes it is read from are refs, and refs are not for reading in render.\n const [drop, setDrop] = useState<{ slot: number; offset: number } | null>(null)\n // The rows' boxes as they were when the drag began; nothing moves during\n // it, so reading them once is reading them right.\n const boxes = useRef<{ top: number; bottom: number }[]>([])\n const listTop = useRef(0)\n\n // The slot under a pointer: how many rows have their middle above it.\n const slotAt = (y: number): number => {\n let count = 0\n for (const box of boxes.current) if ((box.top + box.bottom) / 2 < y) count += 1\n return count\n }\n\n // A slot is an insertion point in the list as drawn; the move wants the\n // index in the list as it will be, with the row gone from where it was.\n const destination = (from: number, at: number): number => (at > from ? at - 1 : at)\n\n const begin = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (disabled || event.button !== 0) return\n const list = listRef.current\n if (list === null) return\n event.preventDefault()\n event.stopPropagation()\n\n listTop.current = list.getBoundingClientRect().top\n // By attribute rather than by selector, so an id needs no escaping.\n const rows = [...list.querySelectorAll<HTMLElement>('[data-reorder-id]')]\n boxes.current = order.map((rowId) => {\n const box = rows.find((row) => row.getAttribute('data-reorder-id') === rowId)?.getBoundingClientRect()\n return box ? { top: box.top, bottom: box.bottom } : { top: 0, bottom: 0 }\n })\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(id)\n setDrop(null)\n }\n\n // The insertion point's distance from the top of the list: the top of the\n // row it goes before, or the bottom of the last row.\n const offsetOf = (at: number): number => {\n const rows = boxes.current\n const edge = at < rows.length ? rows[at]?.top : rows[rows.length - 1]?.bottom\n return (edge ?? 0) - listTop.current\n }\n\n const move = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n // Dropping a row back where it is - the slot just above or just below\n // itself - is no move, and drawing a line there would promise one.\n setDrop(destination(from, at) === from ? null : { slot: at, offset: offsetOf(at) })\n }\n\n const end = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n const to = destination(from, at)\n setDragging(null)\n setDrop(null)\n if (event.type !== 'pointercancel' && to !== from) onMove(id, to)\n }\n\n // Rebuilt every render on purpose: they close over the drag state and the\n // order, and memoising them would mean listing both and getting one wrong.\n const gripProps = (id: K): HTMLAttributes<HTMLElement> => ({\n onPointerDown: (event) => begin(id, event),\n onPointerMove: (event) => move(id, event),\n onPointerUp: (event) => end(id, event),\n onPointerCancel: (event) => end(id, event),\n // The click the drop leaves behind must not reach the row: in a menu it\n // would toggle the item that was only meant to be moved.\n onClick: (event) => event.stopPropagation(),\n })\n\n // The keyboard path is a native listener on the row, not a React one. A\n // menu popup handles the arrow keys itself and stops them on the way up,\n // so a React `onKeyDown` on the item - dispatched from the root, above the\n // popup - never hears them; a listener on the row itself fires at the\n // target first, whatever the ancestors do afterwards. The listener reads\n // the row's id and the latest order off refs rather than closing over\n // them, so one stable callback serves every row and nothing is re-bound\n // on each render.\n const latest = useRef({ order, onMove, disabled })\n useEffect(() => {\n latest.current = { order, onMove, disabled }\n })\n\n const listen = useCallback((row: HTMLElement | null) => {\n if (row === null) return\n const onKeyDown = (event: KeyboardEvent) => {\n const { order: current, onMove: put, disabled: off } = latest.current\n if (off || !event.altKey) return\n const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0\n if (step === 0) return\n const id = row.getAttribute('data-reorder-id') as K | null\n if (id === null) return\n const to = current.indexOf(id) + step\n if (to < 0 || to >= current.length) return\n // Stopped here so the list's own arrow handling does not also walk the\n // focus off the row that was just moved.\n event.preventDefault()\n event.stopPropagation()\n put(id, to)\n }\n row.addEventListener('keydown', onKeyDown)\n return () => row.removeEventListener('keydown', onKeyDown)\n }, [])\n\n const rowProps = (id: K) => ({\n 'data-reorder-id': id,\n ref: listen,\n })\n\n return {\n listProps: {\n ref: (element) => {\n listRef.current = element\n },\n },\n dragging,\n slot: drop?.slot ?? null,\n slotOffset: drop?.offset ?? null,\n gripProps,\n rowProps,\n }\n}\n\n/** The handle a row is picked up by. Decorative to a screen reader - the\n * keyboard path is on the row itself - so it carries no role and no label. */\nexport function ReorderGrip({ className, ...props }: HTMLAttributes<HTMLElement>) {\n return (\n <span\n aria-hidden\n data-reorder-grip\n className={cn(\n 'inline-flex shrink-0 cursor-grab touch-none select-none text-faint',\n 'active:cursor-grabbing [&_svg]:size-3.5',\n className,\n )}\n {...props}\n >\n <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden>\n <circle cx=\"6\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"12.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"12.5\" r=\"1.2\" />\n </svg>\n </span>\n )\n}\n\n/** The line where a dragged row would land. Positioned by the caller from\n * `slotOffset`, inside the element `listProps` is on - which has to be\n * positioned itself. */\nexport function ReorderIndicator({ offset, className }: { offset: number | null; className?: string }) {\n if (offset === null) return null\n return (\n <div\n aria-hidden\n data-reorder-indicator\n className={cn('pointer-events-none absolute inset-x-1 h-0.5 -translate-y-px rounded-full bg-accent', className)}\n style={{ top: offset }}\n />\n )\n}\n"
1694
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drag a row of a vertical list up or down to put it somewhere else.\n *\n * The columns in a column picker, the stops of a dial, the roles of a\n * profile. A hook and a grip rather than a list component: the rows are\n * already something else's - a menu's items, a form's fields - and a\n * component wrapping them would have to reproduce whatever that something\n * else does.\n *\n * Pointer events, not HTML5 drag-and-drop, for the reason given at\n * ColumnResizeHandle: a desktop shell that takes file drops never lets a\n * `dragstart` reach the page. The grip takes pointer capture on pointerdown,\n * so the rows underneath never see the drag and a menu's own highlighting\n * does not flicker down the list as the pointer crosses it.\n *\n * Nothing moves until the pointer is let go. Live reordering looks better\n * for a second and costs a write per crossed row - to a profile, that is a\n * request per row - and the row being dragged has already been picked up,\n * so the reader is watching the line that says where it lands. The hook\n * reports that line as `slot`, and the caller draws it.\n *\n * The keyboard's way is Alt with an arrow on the focused row. Plain arrows\n * are how a list is walked, and taking them for moving would leave no way\n * to walk it; the modifier is the one screen readers and editors already\n * use for \"move this line\".\n */\n\nexport interface ReorderOptions<K extends string> {\n /** The ids in their current order. Every row carries its id in\n * `data-reorder-id`, which `rowProps` sets. */\n order: readonly K[]\n /** Put `id` at index `to` of the resulting list. Called once per drop or\n * per keypress, never during a drag. */\n onMove: (id: K, to: number) => void\n disabled?: boolean\n}\n\nexport interface Reorder<K extends string> {\n /** Spread on the element holding the rows: the rows are found under it.\n * A callback ref rather than a ref object, so that nothing ref-shaped is\n * handed back and the compiler does not take the whole result for one. */\n listProps: { ref: (element: HTMLElement | null) => void }\n /** The row being dragged, or null. */\n dragging: K | null\n /** Where a drop would go, as an insertion point: between the rows at\n * `slot - 1` and `slot`, in the order as it is. Null while nothing is\n * dragged, or while the drop would change nothing. */\n slot: number | null\n /** The insertion point's distance from the top of the list element, in\n * pixels, for drawing the line. */\n slotOffset: number | null\n /** Spread on the grip: the pointer path. */\n gripProps: (id: K) => (HTMLAttributes<HTMLElement>)\n /** Spread on the row: the id and the keyboard path. The `ref` is a\n * callback that returns its cleanup, as React 19 allows; a host that merges\n * refs and drops the cleanup leaves a listener on a node that is gone,\n * which is harmless. */\n rowProps: (id: K) => {\n 'data-reorder-id': K\n ref: (row: HTMLElement | null) => void | (() => void)\n }\n}\n\nexport function useReorder<K extends string>({ order, onMove, disabled }: ReorderOptions<K>): Reorder<K> {\n const listRef = useRef<HTMLElement | null>(null)\n const [dragging, setDragging] = useState<K | null>(null)\n // The slot and where to draw it, settled together in the move handler: the\n // boxes it is read from are refs, and refs are not for reading in render.\n const [drop, setDrop] = useState<{ slot: number; offset: number } | null>(null)\n // The rows' boxes as they were when the drag began; nothing moves during\n // it, so reading them once is reading them right.\n const boxes = useRef<{ top: number; bottom: number }[]>([])\n const listTop = useRef(0)\n\n // The slot under a pointer: how many rows have their middle above it.\n const slotAt = (y: number): number => {\n let count = 0\n for (const box of boxes.current) if ((box.top + box.bottom) / 2 < y) count += 1\n return count\n }\n\n // A slot is an insertion point in the list as drawn; the move wants the\n // index in the list as it will be, with the row gone from where it was.\n const destination = (from: number, at: number): number => (at > from ? at - 1 : at)\n\n const begin = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (disabled || event.button !== 0) return\n const list = listRef.current\n if (list === null) return\n event.preventDefault()\n event.stopPropagation()\n\n listTop.current = list.getBoundingClientRect().top\n // By attribute rather than by selector, so an id needs no escaping.\n const rows = [...list.querySelectorAll<HTMLElement>('[data-reorder-id]')]\n boxes.current = order.map((rowId) => {\n const box = rows.find((row) => row.getAttribute('data-reorder-id') === rowId)?.getBoundingClientRect()\n return box ? { top: box.top, bottom: box.bottom } : { top: 0, bottom: 0 }\n })\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(id)\n setDrop(null)\n }\n\n // The insertion point's distance from the top of the list: the top of the\n // row it goes before, or the bottom of the last row.\n const offsetOf = (at: number): number => {\n const rows = boxes.current\n const edge = at < rows.length ? rows[at]?.top : rows[rows.length - 1]?.bottom\n return (edge ?? 0) - listTop.current\n }\n\n const move = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n // Dropping a row back where it is - the slot just above or just below\n // itself - is no move, and drawing a line there would promise one.\n setDrop(destination(from, at) === from ? null : { slot: at, offset: offsetOf(at) })\n }\n\n const end = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n const to = destination(from, at)\n setDragging(null)\n setDrop(null)\n if (event.type !== 'pointercancel' && to !== from) onMove(id, to)\n }\n\n // Rebuilt every render on purpose: they close over the drag state and the\n // order, and memoising them would mean listing both and getting one wrong.\n const gripProps = (id: K): HTMLAttributes<HTMLElement> => ({\n onPointerDown: (event) => begin(id, event),\n onPointerMove: (event) => move(id, event),\n onPointerUp: (event) => end(id, event),\n onPointerCancel: (event) => end(id, event),\n // The click the drop leaves behind must not reach the row: in a menu it\n // would toggle the item that was only meant to be moved.\n onClick: (event) => event.stopPropagation(),\n })\n\n // The keyboard path is a native listener on the row, not a React one. A\n // menu popup handles the arrow keys itself and stops them on the way up,\n // so a React `onKeyDown` on the item - dispatched from the root, above the\n // popup - never hears them; a listener on the row itself fires at the\n // target first, whatever the ancestors do afterwards. The listener reads\n // the row's id and the latest order off refs rather than closing over\n // them, so one stable callback serves every row and nothing is re-bound\n // on each render.\n const latest = useRef({ order, onMove, disabled })\n useEffect(() => {\n latest.current = { order, onMove, disabled }\n })\n\n const listen = useCallback((row: HTMLElement | null) => {\n if (row === null) return\n const onKeyDown = (event: KeyboardEvent) => {\n const { order: current, onMove: put, disabled: off } = latest.current\n if (off || !event.altKey) return\n const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0\n if (step === 0) return\n const id = row.getAttribute('data-reorder-id') as K | null\n if (id === null) return\n const to = current.indexOf(id) + step\n if (to < 0 || to >= current.length) return\n // Stopped here so the list's own arrow handling does not also walk the\n // focus off the row that was just moved.\n event.preventDefault()\n event.stopPropagation()\n put(id, to)\n }\n row.addEventListener('keydown', onKeyDown)\n return () => row.removeEventListener('keydown', onKeyDown)\n }, [])\n\n const rowProps = (id: K) => ({\n 'data-reorder-id': id,\n ref: listen,\n })\n\n return {\n listProps: {\n ref: (element) => {\n listRef.current = element\n },\n },\n dragging,\n slot: drop?.slot ?? null,\n slotOffset: drop?.offset ?? null,\n gripProps,\n rowProps,\n }\n}\n\n/** The handle a row is picked up by. Decorative to a screen reader - the\n * keyboard path is on the row itself - so it carries no role and no label. */\nexport function ReorderGrip({ className, ...props }: HTMLAttributes<HTMLElement>) {\n return (\n <span\n aria-hidden\n data-reorder-grip\n className={cn(\n 'inline-flex shrink-0 cursor-grab touch-none select-none text-faint',\n 'active:cursor-grabbing [&_svg:not([class*=size-])]:size-3.5',\n className,\n )}\n {...props}\n >\n <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden>\n <circle cx=\"6\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"12.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"12.5\" r=\"1.2\" />\n </svg>\n </span>\n )\n}\n\n/** The line where a dragged row would land. Positioned by the caller from\n * `slotOffset`, inside the element `listProps` is on - which has to be\n * positioned itself. */\nexport function ReorderIndicator({ offset, className }: { offset: number | null; className?: string }) {\n if (offset === null) return null\n return (\n <div\n aria-hidden\n data-reorder-indicator\n className={cn('pointer-events-none absolute inset-x-1 h-0.5 -translate-y-px rounded-full bg-accent', className)}\n style={{ top: offset }}\n />\n )\n}\n"
1544
1695
  }
1545
1696
  ]
1546
1697
  },
@@ -1550,7 +1701,7 @@
1550
1701
  "title": "SaveState",
1551
1702
  "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
1703
  "dependencies": [
1553
- "dowel-ui@^0.30.0"
1704
+ "dowel-ui@^0.32.0"
1554
1705
  ],
1555
1706
  "registryDependencies": [
1556
1707
  "https://lacodda.github.io/dowel/r/spinner.json"
@@ -1564,13 +1715,33 @@
1564
1715
  }
1565
1716
  ]
1566
1717
  },
1718
+ {
1719
+ "name": "scroll-area",
1720
+ "type": "registry:ui",
1721
+ "title": "ScrollArea",
1722
+ "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.",
1723
+ "dependencies": [
1724
+ "@base-ui/react",
1725
+ "class-variance-authority",
1726
+ "dowel-ui@^0.32.0"
1727
+ ],
1728
+ "registryDependencies": [],
1729
+ "files": [
1730
+ {
1731
+ "path": "ui/scroll-area.tsx",
1732
+ "target": "@ui/scroll-area.tsx",
1733
+ "type": "registry:ui",
1734
+ "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"
1735
+ }
1736
+ ]
1737
+ },
1567
1738
  {
1568
1739
  "name": "search-field",
1569
1740
  "type": "registry:ui",
1570
1741
  "title": "SearchField",
1571
1742
  "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
1743
  "dependencies": [
1573
- "dowel-ui@^0.30.0"
1744
+ "dowel-ui@^0.32.0"
1574
1745
  ],
1575
1746
  "registryDependencies": [
1576
1747
  "https://lacodda.github.io/dowel/r/input.json",
@@ -1593,7 +1764,7 @@
1593
1764
  "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
1765
  "dependencies": [
1595
1766
  "@base-ui/react",
1596
- "dowel-ui@^0.30.0"
1767
+ "dowel-ui@^0.32.0"
1597
1768
  ],
1598
1769
  "registryDependencies": [],
1599
1770
  "files": [
@@ -1601,7 +1772,7 @@
1601
1772
  "path": "ui/section-nav.tsx",
1602
1773
  "target": "@ui/section-nav.tsx",
1603
1774
  "type": "registry:ui",
1604
- "content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function SectionNav({ label, items, activeId, render, onSelect, className, ...props }: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className=\"px-2.5 pb-2 text-2xs font-medium uppercase tracking-caption text-faint\">\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n className: cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n 'hover:bg-soft hover:text-text [&_svg]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {/* One line, truncated, like a NavRail entry. A section column is a\n fixed width, and a label that wraps makes one row taller than\n every other - which in a two-language product happens to one\n section and not the rest, so the column reads as ragged and the\n line height stops meaning anything. `min-w-0` because a flex\n child will not shrink below its content without it. */}\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
1775
+ "content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function SectionNav({ label, items, activeId, render, onSelect, className, ...props }: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className=\"px-2.5 pb-2 text-2xs font-medium uppercase tracking-caption text-faint\">\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n className: cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n 'hover:bg-soft hover:text-text [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {/* One line, truncated, like a NavRail entry. A section column is a\n fixed width, and a label that wraps makes one row taller than\n every other - which in a two-language product happens to one\n section and not the rest, so the column reads as ragged and the\n line height stops meaning anything. `min-w-0` because a flex\n child will not shrink below its content without it. */}\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
1605
1776
  }
1606
1777
  ]
1607
1778
  },
@@ -1613,17 +1784,18 @@
1613
1784
  "dependencies": [
1614
1785
  "@base-ui/react",
1615
1786
  "class-variance-authority",
1616
- "dowel-ui@^0.30.0"
1787
+ "dowel-ui@^0.32.0"
1617
1788
  ],
1618
1789
  "registryDependencies": [
1619
- "https://lacodda.github.io/dowel/r/input.json"
1790
+ "https://lacodda.github.io/dowel/r/input.json",
1791
+ "https://lacodda.github.io/dowel/r/layer.json"
1620
1792
  ],
1621
1793
  "files": [
1622
1794
  {
1623
1795
  "path": "ui/select.tsx",
1624
1796
  "target": "@ui/select.tsx",
1625
1797
  "type": "registry:ui",
1626
- "content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const selectPopupVariants = cva(\n [\n 'max-h-[min(24rem,var(--available-height))] overflow-y-auto',\n 'rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n // What is already chosen has to be visible in the list, and colour alone\n // will not do it: `text-text` on an item that is already `text-text` says\n // nothing. The tick below is the state; this is the emphasis that goes with\n // it, so the row reads as chosen at a glance and not only under the eye.\n 'data-[selected]:font-medium data-[selected]:text-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in.\n *\n * It truncates, and that matters most for `multiple`: nine chosen fruits are\n * one long string, and without this the trigger either grows into a paragraph\n * or spills its text past its own border. One line, an ellipsis, and the full\n * set is still in the list where the ticks are. */\nexport function SelectValue({ className, ...props }: Base.Value.Props) {\n return <Base.Value className={cn('min-w-0 flex-1 truncate text-left', className)} {...props} />\n}\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the list\n * from being clipped by a form with `overflow: hidden`. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option, with the tick that says it is the chosen one.\n *\n * The indicator is built in rather than left to the caller. The item already\n * reserves the room for it (`pr-7`), and a dropdown that does not show what is\n * currently selected is the commonest complaint about a styled select: it\n * opens, and the reader has to remember what they picked last time. Passing\n * `indicator={false}` turns it off for a list where the choice is obvious\n * some other way. */\nexport function SelectItem({\n indicator = true,\n className,\n children,\n ...props\n}: Base.Item.Props & { indicator?: boolean }) {\n return (\n <Base.Item className={cn(selectItemVariants(), className)} {...props}>\n {children}\n {indicator && (\n <Base.ItemIndicator className=\"absolute right-2 flex text-accent\">\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.ItemIndicator>\n )}\n </Base.Item>\n )\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
1798
+ "content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const selectPopupVariants = cva(\n [\n 'max-h-[min(24rem,var(--available-height))] overflow-y-auto',\n 'rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n // What is already chosen has to be visible in the list, and colour alone\n // will not do it: `text-text` on an item that is already `text-text` says\n // nothing. The tick below is the state; this is the emphasis that goes with\n // it, so the row reads as chosen at a glance and not only under the eye.\n 'data-[selected]:font-medium data-[selected]:text-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg:not([class*=size-])]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in.\n *\n * It truncates, and that matters most for `multiple`: nine chosen fruits are\n * one long string, and without this the trigger either grows into a paragraph\n * or spills its text past its own border. One line, an ellipsis, and the full\n * set is still in the list where the ticks are. */\nexport function SelectValue({ className, ...props }: Base.Value.Props) {\n return <Base.Value className={cn('min-w-0 flex-1 truncate text-left', className)} {...props} />\n}\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option, with the tick that says it is the chosen one.\n *\n * The indicator is built in rather than left to the caller. The item already\n * reserves the room for it (`pr-7`), and a dropdown that does not show what is\n * currently selected is the commonest complaint about a styled select: it\n * opens, and the reader has to remember what they picked last time. Passing\n * `indicator={false}` turns it off for a list where the choice is obvious\n * some other way. */\nexport function SelectItem({\n indicator = true,\n className,\n children,\n ...props\n}: Base.Item.Props & { indicator?: boolean }) {\n return (\n <Base.Item className={cn(selectItemVariants(), className)} {...props}>\n {children}\n {indicator && (\n <Base.ItemIndicator className=\"absolute right-2 flex text-accent\">\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.ItemIndicator>\n )}\n </Base.Item>\n )\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
1627
1799
  }
1628
1800
  ]
1629
1801
  },
@@ -1649,7 +1821,7 @@
1649
1821
  "title": "SkeletonOf",
1650
1822
  "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
1823
  "dependencies": [
1652
- "dowel-ui@^0.30.0"
1824
+ "dowel-ui@^0.32.0"
1653
1825
  ],
1654
1826
  "registryDependencies": [],
1655
1827
  "files": [
@@ -1667,7 +1839,7 @@
1667
1839
  "title": "Skeleton",
1668
1840
  "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
1841
  "dependencies": [
1670
- "dowel-ui@^0.30.0"
1842
+ "dowel-ui@^0.32.0"
1671
1843
  ],
1672
1844
  "registryDependencies": [],
1673
1845
  "files": [
@@ -1686,7 +1858,7 @@
1686
1858
  "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
1859
  "dependencies": [
1688
1860
  "@base-ui/react",
1689
- "dowel-ui@^0.30.0"
1861
+ "dowel-ui@^0.32.0"
1690
1862
  ],
1691
1863
  "registryDependencies": [],
1692
1864
  "files": [
@@ -1705,7 +1877,7 @@
1705
1877
  "description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
1706
1878
  "dependencies": [
1707
1879
  "class-variance-authority",
1708
- "dowel-ui@^0.30.0"
1880
+ "dowel-ui@^0.32.0"
1709
1881
  ],
1710
1882
  "registryDependencies": [],
1711
1883
  "files": [
@@ -1724,7 +1896,7 @@
1724
1896
  "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
1897
  "dependencies": [
1726
1898
  "class-variance-authority",
1727
- "dowel-ui@^0.30.0"
1899
+ "dowel-ui@^0.32.0"
1728
1900
  ],
1729
1901
  "registryDependencies": [],
1730
1902
  "files": [
@@ -1742,7 +1914,7 @@
1742
1914
  "title": "Splash",
1743
1915
  "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
1916
  "dependencies": [
1745
- "dowel-ui@^0.30.0"
1917
+ "dowel-ui@^0.32.0"
1746
1918
  ],
1747
1919
  "registryDependencies": [],
1748
1920
  "files": [
@@ -1750,7 +1922,26 @@
1750
1922
  "path": "ui/splash.tsx",
1751
1923
  "target": "@ui/splash.tsx",
1752
1924
  "type": "registry:ui",
1753
- "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * What the window shows while the application is opening.\n *\n * A desktop product has a second or two between the window appearing and\n * the first screen being ready - a workspace to open, a database to migrate,\n * a plugin to start - and a blank window for that long reads as a crash. So\n * the window shows the product instead: the mark, the name, the promise, the\n * version, and a bar that sweeps until there is something to draw.\n *\n * It is the second half of a pattern, and the first half is not React. The\n * page paints the same picture in inline CSS before the bundle arrives, so\n * that the window is never blank at all; this component takes over from it\n * on the first render, with the same geometry so nothing jumps, and adds the\n * two lines only the application can say - what it is doing, in the person's\n * language, and one thing worth knowing. The static half is in the docs.\n *\n * It is a status region, not a dialog: the reader is told what is happening\n * and cannot act on it. The sweep is a `<style>` of its own rather than a\n * theme keyframe, because a product installs the theme on its first day and\n * this once; and under reduced motion the theme stops every animation dead,\n * which would leave the sweep parked off the end of its track - so the bar\n * is drawn full and still instead.\n */\n\nexport interface SplashProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The product's mark, drawn at 56px. */\n mark?: ReactNode\n /** The product's name. */\n name: string\n /** The line under the name. */\n tagline?: string\n /** The version, drawn in the mono face; the `v` is the product's to add. */\n version?: string\n /** What the application is doing right now. */\n status?: string\n /** One thing worth knowing while it does it. */\n tip?: string\n /** Whether the bar is sweeping. Still and full when the application is\n * waiting on something that has no progress, such as a person. */\n busy?: boolean\n}\n\n/** The sweep, named with a prefix so a product's own `sweep` cannot collide\n * with it in the one document both end up in.\n *\n * The class below writes the name out rather than interpolating this\n * constant, and that is not carelessness: Tailwind finds its classes by\n * scanning the source text, and a class assembled from a template is one it\n * never sees - the bar rendered with the right class name and no CSS behind\n * it, and the stand showed an empty track. The test compiles the class to\n * make sure the two spellings agree. */\nconst SWEEP = 'dowel-splash-sweep'\nconst SWEEPING = 'animate-[dowel-splash-sweep_1.1s_ease-in-out_infinite]'\n\nexport function Splash({\n mark,\n name,\n tagline,\n version,\n status,\n tip,\n busy = true,\n className,\n ...props\n}: SplashProps) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className={cn(\n 'fixed inset-0 flex flex-col items-center justify-center gap-2.5 bg-bg text-text select-none',\n className,\n )}\n {...props}\n >\n <style>{`@keyframes ${SWEEP} { to { left: 100%; } }`}</style>\n {mark ? <div className=\"mb-1.5 size-14 [&>svg]:size-full\">{mark}</div> : null}\n <div className=\"text-[26px] leading-none font-semibold tracking-[0.02em]\">{name}</div>\n {tagline ? <div className=\"text-sm text-dim\">{tagline}</div> : null}\n {version ? <div className=\"mt-1.5 font-mono text-2xs text-faint\">{version}</div> : null}\n <div className=\"relative mt-4 h-0.5 w-40 overflow-hidden rounded-full bg-line\">\n <div\n data-sweep={busy ? 'on' : 'off'}\n className={cn(\n 'absolute top-0 h-full rounded-full bg-accent',\n busy\n ? cn(\n '-left-2/5 w-2/5',\n SWEEPING,\n 'motion-reduce:left-0 motion-reduce:w-full motion-reduce:animate-none',\n )\n : 'left-0 w-full',\n )}\n />\n </div>\n {status ? <p className=\"mt-3 text-xs text-dim\">{status}</p> : null}\n {tip ? <p className=\"text-2xs text-faint\">{tip}</p> : null}\n </div>\n )\n}\n"
1925
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * What the window shows while the application is opening.\n *\n * A desktop product has a second or two between the window appearing and\n * the first screen being ready - a workspace to open, a database to migrate,\n * a plugin to start - and a blank window for that long reads as a crash. So\n * the window shows the product instead: the mark, the name, the promise, the\n * version, and a bar that sweeps until there is something to draw.\n *\n * It is the second half of a pattern, and the first half is not React. The\n * page paints the same picture in inline CSS before the bundle arrives, so\n * that the window is never blank at all; this component takes over from it\n * on the first render, with the same geometry so nothing jumps, and adds the\n * two lines only the application can say - what it is doing, in the person's\n * language, and one thing worth knowing. The static half is in the docs.\n *\n * It is a status region, not a dialog: the reader is told what is happening\n * and cannot act on it. The sweep is a `<style>` of its own rather than a\n * theme keyframe, because a product installs the theme on its first day and\n * this once; and under reduced motion the theme stops every animation dead,\n * which would leave the sweep parked off the end of its track - so the bar\n * is drawn full and still instead.\n */\n\nexport interface SplashProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The product's mark, drawn at 56px. */\n mark?: ReactNode\n /** The product's name. */\n name: string\n /** The line under the name. */\n tagline?: string\n /** The version, drawn in the mono face; the `v` is the product's to add. */\n version?: string\n /** What the application is doing right now. */\n status?: string\n /** One thing worth knowing while it does it. */\n tip?: string\n /** Whether the bar is sweeping. Still and full when the application is\n * waiting on something that has no progress, such as a person. */\n busy?: boolean\n}\n\n/** The sweep, named with a prefix so a product's own `sweep` cannot collide\n * with it in the one document both end up in.\n *\n * The class below writes the name out rather than interpolating this\n * constant, and that is not carelessness: Tailwind finds its classes by\n * scanning the source text, and a class assembled from a template is one it\n * never sees - the bar rendered with the right class name and no CSS behind\n * it, and the stand showed an empty track. The test compiles the class to\n * make sure the two spellings agree. */\nconst SWEEP = 'dowel-splash-sweep'\nconst SWEEPING = 'animate-[dowel-splash-sweep_1.1s_ease-in-out_infinite]'\n\nexport function Splash({\n mark,\n name,\n tagline,\n version,\n status,\n tip,\n busy = true,\n className,\n ...props\n}: SplashProps) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className={cn(\n 'fixed inset-0 flex flex-col items-center justify-center gap-2.5 bg-bg text-text select-none',\n className,\n )}\n {...props}\n >\n <style>{`@keyframes ${SWEEP} { to { left: 100%; } }`}</style>\n {mark ? <div className=\"mb-1.5 size-14 [&>svg:not([class*=size-])]:size-full\">{mark}</div> : null}\n <div className=\"text-[26px] leading-none font-semibold tracking-[0.02em]\">{name}</div>\n {tagline ? <div className=\"text-sm text-dim\">{tagline}</div> : null}\n {version ? <div className=\"mt-1.5 font-mono text-2xs text-faint\">{version}</div> : null}\n <div className=\"relative mt-4 h-0.5 w-40 overflow-hidden rounded-full bg-line\">\n <div\n data-sweep={busy ? 'on' : 'off'}\n className={cn(\n 'absolute top-0 h-full rounded-full bg-accent',\n busy\n ? cn(\n '-left-2/5 w-2/5',\n SWEEPING,\n 'motion-reduce:left-0 motion-reduce:w-full motion-reduce:animate-none',\n )\n : 'left-0 w-full',\n )}\n />\n </div>\n {status ? <p className=\"mt-3 text-xs text-dim\">{status}</p> : null}\n {tip ? <p className=\"text-2xs text-faint\">{tip}</p> : null}\n </div>\n )\n}\n"
1926
+ }
1927
+ ]
1928
+ },
1929
+ {
1930
+ "name": "splitter",
1931
+ "type": "registry:ui",
1932
+ "title": "Splitter",
1933
+ "description": "The splitter primitive.",
1934
+ "dependencies": [
1935
+ "class-variance-authority",
1936
+ "dowel-ui@^0.32.0"
1937
+ ],
1938
+ "registryDependencies": [],
1939
+ "files": [
1940
+ {
1941
+ "path": "ui/splitter.tsx",
1942
+ "target": "@ui/splitter.tsx",
1943
+ "type": "registry:ui",
1944
+ "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"
1754
1945
  }
1755
1946
  ]
1756
1947
  },
@@ -1761,7 +1952,7 @@
1761
1952
  "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
1953
  "dependencies": [
1763
1954
  "class-variance-authority",
1764
- "dowel-ui@^0.30.0"
1955
+ "dowel-ui@^0.32.0"
1765
1956
  ],
1766
1957
  "registryDependencies": [],
1767
1958
  "files": [
@@ -1780,7 +1971,7 @@
1780
1971
  "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
1972
  "dependencies": [
1782
1973
  "class-variance-authority",
1783
- "dowel-ui@^0.30.0"
1974
+ "dowel-ui@^0.32.0"
1784
1975
  ],
1785
1976
  "registryDependencies": [],
1786
1977
  "files": [
@@ -1792,6 +1983,25 @@
1792
1983
  }
1793
1984
  ]
1794
1985
  },
1986
+ {
1987
+ "name": "stepper",
1988
+ "type": "registry:ui",
1989
+ "title": "Stepper",
1990
+ "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.",
1991
+ "dependencies": [
1992
+ "class-variance-authority",
1993
+ "dowel-ui@^0.32.0"
1994
+ ],
1995
+ "registryDependencies": [],
1996
+ "files": [
1997
+ {
1998
+ "path": "ui/stepper.tsx",
1999
+ "target": "@ui/stepper.tsx",
2000
+ "type": "registry:ui",
2001
+ "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"
2002
+ }
2003
+ ]
2004
+ },
1795
2005
  {
1796
2006
  "name": "switch",
1797
2007
  "type": "registry:ui",
@@ -1799,7 +2009,7 @@
1799
2009
  "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
2010
  "dependencies": [
1801
2011
  "@base-ui/react",
1802
- "dowel-ui@^0.30.0"
2012
+ "dowel-ui@^0.32.0"
1803
2013
  ],
1804
2014
  "registryDependencies": [],
1805
2015
  "files": [
@@ -1823,7 +2033,7 @@
1823
2033
  "path": "ui/table-sort.tsx",
1824
2034
  "target": "@ui/table-sort.tsx",
1825
2035
  "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"
2036
+ "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
2037
  }
1828
2038
  ]
1829
2039
  },
@@ -1834,7 +2044,7 @@
1834
2044
  "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
2045
  "dependencies": [
1836
2046
  "class-variance-authority",
1837
- "dowel-ui@^0.30.0"
2047
+ "dowel-ui@^0.32.0"
1838
2048
  ],
1839
2049
  "registryDependencies": [
1840
2050
  "https://lacodda.github.io/dowel/r/table-sort.json"
@@ -1848,6 +2058,26 @@
1848
2058
  }
1849
2059
  ]
1850
2060
  },
2061
+ {
2062
+ "name": "tabs",
2063
+ "type": "registry:ui",
2064
+ "title": "Tabs",
2065
+ "description": "The tabs primitive.",
2066
+ "dependencies": [
2067
+ "@base-ui/react",
2068
+ "class-variance-authority",
2069
+ "dowel-ui@^0.32.0"
2070
+ ],
2071
+ "registryDependencies": [],
2072
+ "files": [
2073
+ {
2074
+ "path": "ui/tabs.tsx",
2075
+ "target": "@ui/tabs.tsx",
2076
+ "type": "registry:ui",
2077
+ "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 // An icon beside a tab's name is sized like one in a small button: at its\n // own 24px it stood taller than the text it labels.\n '[&_svg:not([class*=size-])]:size-3.5 [&_svg]:shrink-0',\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-control-sm 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"
2078
+ }
2079
+ ]
2080
+ },
1851
2081
  {
1852
2082
  "name": "tag-input",
1853
2083
  "type": "registry:ui",
@@ -1855,7 +2085,7 @@
1855
2085
  "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
1856
2086
  "dependencies": [
1857
2087
  "class-variance-authority",
1858
- "dowel-ui@^0.30.0"
2088
+ "dowel-ui@^0.32.0"
1859
2089
  ],
1860
2090
  "registryDependencies": [
1861
2091
  "https://lacodda.github.io/dowel/r/chip.json",
@@ -1876,7 +2106,7 @@
1876
2106
  "title": "Textarea",
1877
2107
  "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
2108
  "dependencies": [
1879
- "dowel-ui@^0.30.0"
2109
+ "dowel-ui@^0.32.0"
1880
2110
  ],
1881
2111
  "registryDependencies": [
1882
2112
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1897,7 +2127,7 @@
1897
2127
  "description": "The tier primitive.",
1898
2128
  "dependencies": [
1899
2129
  "class-variance-authority",
1900
- "dowel-ui@^0.30.0"
2130
+ "dowel-ui@^0.32.0"
1901
2131
  ],
1902
2132
  "registryDependencies": [],
1903
2133
  "files": [
@@ -1915,7 +2145,7 @@
1915
2145
  "title": "TimeField",
1916
2146
  "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
2147
  "dependencies": [
1918
- "dowel-ui@^0.30.0"
2148
+ "dowel-ui@^0.32.0"
1919
2149
  ],
1920
2150
  "registryDependencies": [
1921
2151
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1925,7 +2155,7 @@
1925
2155
  "path": "ui/time-field.tsx",
1926
2156
  "target": "@ui/time-field.tsx",
1927
2157
  "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"
2158
+ "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
2159
  }
1930
2160
  ]
1931
2161
  },
@@ -1936,7 +2166,7 @@
1936
2166
  "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
2167
  "dependencies": [
1938
2168
  "class-variance-authority",
1939
- "dowel-ui@^0.30.0"
2169
+ "dowel-ui@^0.32.0"
1940
2170
  ],
1941
2171
  "registryDependencies": [],
1942
2172
  "files": [
@@ -1956,7 +2186,7 @@
1956
2186
  "dependencies": [
1957
2187
  "@base-ui/react",
1958
2188
  "class-variance-authority",
1959
- "dowel-ui@^0.30.0"
2189
+ "dowel-ui@^0.32.0"
1960
2190
  ],
1961
2191
  "registryDependencies": [],
1962
2192
  "files": [
@@ -1976,15 +2206,17 @@
1976
2206
  "dependencies": [
1977
2207
  "@base-ui/react",
1978
2208
  "class-variance-authority",
1979
- "dowel-ui@^0.30.0"
2209
+ "dowel-ui@^0.32.0"
2210
+ ],
2211
+ "registryDependencies": [
2212
+ "https://lacodda.github.io/dowel/r/layer.json"
1980
2213
  ],
1981
- "registryDependencies": [],
1982
2214
  "files": [
1983
2215
  {
1984
2216
  "path": "ui/tooltip.tsx",
1985
2217
  "target": "@ui/tooltip.tsx",
1986
2218
  "type": "registry:ui",
1987
- "content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-raise',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-hair data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-hair data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-hair data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-hair data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
2219
+ "content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-raise',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-hair data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-hair data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-hair data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-hair data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1988
2220
  }
1989
2221
  ]
1990
2222
  },
@@ -2011,7 +2243,7 @@
2011
2243
  "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
2244
  "dependencies": [
2013
2245
  "class-variance-authority",
2014
- "dowel-ui@^0.30.0"
2246
+ "dowel-ui@^0.32.0"
2015
2247
  ],
2016
2248
  "registryDependencies": [
2017
2249
  "https://lacodda.github.io/dowel/r/track-segments.json"
@@ -2047,7 +2279,7 @@
2047
2279
  "title": "TreeView",
2048
2280
  "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
2281
  "dependencies": [
2050
- "dowel-ui@^0.30.0"
2282
+ "dowel-ui@^0.32.0"
2051
2283
  ],
2052
2284
  "registryDependencies": [
2053
2285
  "https://lacodda.github.io/dowel/r/tree-rows.json"
@@ -2067,7 +2299,7 @@
2067
2299
  "title": "Truncate",
2068
2300
  "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
2301
  "dependencies": [
2070
- "dowel-ui@^0.30.0"
2302
+ "dowel-ui@^0.32.0"
2071
2303
  ],
2072
2304
  "registryDependencies": [],
2073
2305
  "files": [
@@ -2085,7 +2317,7 @@
2085
2317
  "title": "VirtualList",
2086
2318
  "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
2319
  "dependencies": [
2088
- "dowel-ui@^0.30.0"
2320
+ "dowel-ui@^0.32.0"
2089
2321
  ],
2090
2322
  "registryDependencies": [],
2091
2323
  "files": [
@@ -2104,7 +2336,7 @@
2104
2336
  "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
2337
  "dependencies": [
2106
2338
  "@tauri-apps/api",
2107
- "dowel-ui@^0.30.0"
2339
+ "dowel-ui@^0.32.0"
2108
2340
  ],
2109
2341
  "registryDependencies": [],
2110
2342
  "files": [
@@ -2112,7 +2344,28 @@
2112
2344
  "path": "ui/window-frame.tsx",
2113
2345
  "target": "@ui/window-frame.tsx",
2114
2346
  "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"
2347
+ "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"
2348
+ }
2349
+ ]
2350
+ },
2351
+ {
2352
+ "name": "wizard",
2353
+ "type": "registry:ui",
2354
+ "title": "Wizard",
2355
+ "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.",
2356
+ "dependencies": [
2357
+ "dowel-ui@^0.32.0"
2358
+ ],
2359
+ "registryDependencies": [
2360
+ "https://lacodda.github.io/dowel/r/button.json",
2361
+ "https://lacodda.github.io/dowel/r/stepper.json"
2362
+ ],
2363
+ "files": [
2364
+ {
2365
+ "path": "ui/wizard.tsx",
2366
+ "target": "@ui/wizard.tsx",
2367
+ "type": "registry:ui",
2368
+ "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
2369
  }
2117
2370
  ]
2118
2371
  },
@@ -2131,6 +2384,7 @@
2131
2384
  "https://lacodda.github.io/dowel/r/drawer.json",
2132
2385
  "https://lacodda.github.io/dowel/r/input.json",
2133
2386
  "https://lacodda.github.io/dowel/r/kbd.json",
2387
+ "https://lacodda.github.io/dowel/r/layer.json",
2134
2388
  "https://lacodda.github.io/dowel/r/menu.json",
2135
2389
  "https://lacodda.github.io/dowel/r/panel.json",
2136
2390
  "https://lacodda.github.io/dowel/r/select.json",
@@ -2156,6 +2410,7 @@
2156
2410
  "https://lacodda.github.io/dowel/r/field.json",
2157
2411
  "https://lacodda.github.io/dowel/r/file-drop.json",
2158
2412
  "https://lacodda.github.io/dowel/r/input.json",
2413
+ "https://lacodda.github.io/dowel/r/layer.json",
2159
2414
  "https://lacodda.github.io/dowel/r/number-field.json",
2160
2415
  "https://lacodda.github.io/dowel/r/password-field.json",
2161
2416
  "https://lacodda.github.io/dowel/r/radio-group.json",
@@ -2178,6 +2433,8 @@
2178
2433
  "https://lacodda.github.io/dowel/r/alert.json",
2179
2434
  "https://lacodda.github.io/dowel/r/banner.json",
2180
2435
  "https://lacodda.github.io/dowel/r/confirm-dialog.json",
2436
+ "https://lacodda.github.io/dowel/r/dialog.json",
2437
+ "https://lacodda.github.io/dowel/r/layer.json",
2181
2438
  "https://lacodda.github.io/dowel/r/toast.json"
2182
2439
  ],
2183
2440
  "docs": "Which of the four to reach for is the harder question: see https://lacodda.github.io/dowel/guides/overlays/"