dowel-ui 0.17.1 → 0.18.2

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ A dowel is the hidden peg that joins two boards so the seam does not show. That
11
11
  **[Documentation](https://lacodda.github.io/dowel/)** — what everything is and why it is that way.
12
12
  **[The stand](https://lacodda.github.io/dowel/stand/)** — every component, live, in either theme and in the accent of any product of the line.
13
13
 
14
- **Status:** v0.17.1 - the theme, the scales, an accent per product, thirty-five components - overlays, menus, the command palette and the three ways of saying something happened - and the gates each one passes: axe, the keyboard, a dependency budget and a picture in both themes. Components install from a versioned registry, the docs are served in the form an agent reads, and `dowel check` tells a project what stands between it and the vocabulary. Two products of the line live on it. See the [roadmap](#roadmap).
14
+ **Status:** v0.18.2 - the theme, the scales, an accent per product, forty components - overlays, menus, the command palette and the three ways of saying something happened - and the gates each one passes: axe, the keyboard, a dependency budget and a picture in both themes. Components install from a versioned registry, the docs are served in the form an agent reads, and `dowel check` tells a project what stands between it and the vocabulary. Two products of the line live on it. See the [roadmap](#roadmap).
15
15
 
16
16
  ## The theme
17
17
 
@@ -98,15 +98,16 @@ snapshot the cross-references point into the same snapshot, so a component and
98
98
  the sibling it reuses are the pair that shipped together. See
99
99
  [installing from the registry](https://lacodda.github.io/dowel/guides/registry/).
100
100
 
101
- Thirty-five of them so far. The everyday ones - Button, Input, Textarea, Panel,
101
+ Forty of them so far. The everyday ones - Button, Input, Textarea, Panel,
102
102
  Badge, Chip, Kbd, Spinner, Truncate and Copyable; Field and the three controls
103
103
  that answer a question - Checkbox, RadioGroup and Switch; five for a number or
104
104
  a judgement - NumberField, Slider, RatingScale, DurationField and
105
- PasswordField; the six that float above the page - Dialog, ConfirmDialog,
106
- Drawer, Popover, PreviewCard and Tooltip; four for choosing something - Menu,
107
- ContextMenu, Select and Combobox; three for finding it - SearchField,
108
- CommandPalette and the `useShortcut` behind them; and three for saying that
109
- something happened: Toast, Alert and Banner.
105
+ PasswordField; five for a date or a time - Calendar, DatePicker,
106
+ DateRangePicker, TimeField and the `calendar-math` under them; the six that
107
+ float above the page - Dialog, ConfirmDialog, Drawer, Popover, PreviewCard and
108
+ Tooltip; four for choosing something - Menu, ContextMenu, Select and Combobox;
109
+ three for finding it - SearchField, CommandPalette and the `useShortcut` behind
110
+ them; and three for saying that something happened: Toast, Alert and Banner.
110
111
 
111
112
  Those last three are the ones products keep confusing, so each page names all
112
113
  four options: a **toast** goes away, an **alert** is still true after a reload,
@@ -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 /* 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. `raise` keeps its original value, so nothing shifts under\n * the products already using it; the other two are the steps either side. */\n --shadow-lift: 0 2px 8px rgb(0 0 0 / 0.3);\n --shadow-raise: 0 10px 34px rgb(0 0 0 / 0.45);\n --shadow-float: 0 24px 60px rgb(0 0 0 / 0.55);\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 --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 --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 /* 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 /* 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/*\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:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\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}\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 /* 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. `raise` keeps its original value, so nothing shifts under\n * the products already using it; the other two are the steps either side. */\n --shadow-lift: 0 2px 8px rgb(0 0 0 / 0.3);\n --shadow-raise: 0 10px 34px rgb(0 0 0 / 0.45);\n --shadow-float: 0 24px 60px rgb(0 0 0 / 0.55);\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 --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 --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 /* 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 /* 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/*\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}\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; }"
@@ -222,7 +222,7 @@
222
222
  "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.",
223
223
  "dependencies": [
224
224
  "class-variance-authority",
225
- "dowel-ui@^0.17.1"
225
+ "dowel-ui@^0.18.2"
226
226
  ],
227
227
  "registryDependencies": [],
228
228
  "files": [
@@ -241,7 +241,7 @@
241
241
  "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.",
242
242
  "dependencies": [
243
243
  "class-variance-authority",
244
- "dowel-ui@^0.17.1"
244
+ "dowel-ui@^0.18.2"
245
245
  ],
246
246
  "registryDependencies": [],
247
247
  "files": [
@@ -260,7 +260,7 @@
260
260
  "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.",
261
261
  "dependencies": [
262
262
  "class-variance-authority",
263
- "dowel-ui@^0.17.1"
263
+ "dowel-ui@^0.18.2"
264
264
  ],
265
265
  "registryDependencies": [],
266
266
  "files": [
@@ -280,7 +280,7 @@
280
280
  "dependencies": [
281
281
  "@base-ui/react",
282
282
  "class-variance-authority",
283
- "dowel-ui@^0.17.1"
283
+ "dowel-ui@^0.18.2"
284
284
  ],
285
285
  "registryDependencies": [],
286
286
  "files": [
@@ -292,6 +292,42 @@
292
292
  }
293
293
  ]
294
294
  },
295
+ {
296
+ "name": "calendar-math",
297
+ "type": "registry:ui",
298
+ "title": "Calendar-math",
299
+ "description": "Split out of the Calendar because the size gate asked the right question: the file was two and a half times over its ceiling, and the reason was that it held two things - the sums, and the grid that draws them. These are the sums, and they are what DatePicker, DateRangePicker and any product doing its own date work import.",
300
+ "dependencies": [],
301
+ "registryDependencies": [],
302
+ "files": [
303
+ {
304
+ "path": "ui/calendar-math.tsx",
305
+ "target": "@ui/calendar-math.tsx",
306
+ "type": "registry:ui",
307
+ "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"
308
+ }
309
+ ]
310
+ },
311
+ {
312
+ "name": "calendar",
313
+ "type": "registry:ui",
314
+ "title": "Calendar",
315
+ "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.",
316
+ "dependencies": [
317
+ "dowel-ui@^0.18.2"
318
+ ],
319
+ "registryDependencies": [
320
+ "https://lacodda.github.io/dowel/r/calendar-math.json"
321
+ ],
322
+ "files": [
323
+ {
324
+ "path": "ui/calendar.tsx",
325
+ "target": "@ui/calendar.tsx",
326
+ "type": "registry:ui",
327
+ "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"
328
+ }
329
+ ]
330
+ },
295
331
  {
296
332
  "name": "checkbox",
297
333
  "type": "registry:ui",
@@ -299,7 +335,7 @@
299
335
  "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.",
300
336
  "dependencies": [
301
337
  "@base-ui/react",
302
- "dowel-ui@^0.17.1"
338
+ "dowel-ui@^0.18.2"
303
339
  ],
304
340
  "registryDependencies": [],
305
341
  "files": [
@@ -318,7 +354,7 @@
318
354
  "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.",
319
355
  "dependencies": [
320
356
  "class-variance-authority",
321
- "dowel-ui@^0.17.1"
357
+ "dowel-ui@^0.18.2"
322
358
  ],
323
359
  "registryDependencies": [],
324
360
  "files": [
@@ -338,7 +374,7 @@
338
374
  "dependencies": [
339
375
  "@base-ui/react",
340
376
  "class-variance-authority",
341
- "dowel-ui@^0.17.1"
377
+ "dowel-ui@^0.18.2"
342
378
  ],
343
379
  "registryDependencies": [
344
380
  "https://lacodda.github.io/dowel/r/input.json",
@@ -349,7 +385,7 @@
349
385
  "path": "ui/combobox.tsx",
350
386
  "target": "@ui/combobox.tsx",
351
387
  "type": "registry:ui",
352
- "content": "import { 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\nexport const comboboxInputVariants = cva([fieldClasses], {\n variants: {\n size: {\n sm: 'h-8 text-xs',\n md: 'h-9',\n lg: 'h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\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. */\nexport const ComboboxChips = Base.Chips\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\">`. */\nexport function ComboboxInput({ size, className, ...props }: ComboboxInputProps) {\n return <Base.Input className={cn(comboboxInputVariants({ size }), 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 /** 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. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\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 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"
388
+ "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-8 text-xs',\n md: 'h-9',\n lg: 'h-10 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 /** 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. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\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 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"
353
389
  }
354
390
  ]
355
391
  },
@@ -361,7 +397,7 @@
361
397
  "dependencies": [
362
398
  "@base-ui/react",
363
399
  "class-variance-authority",
364
- "dowel-ui@^0.17.1"
400
+ "dowel-ui@^0.18.2"
365
401
  ],
366
402
  "registryDependencies": [
367
403
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -372,7 +408,7 @@
372
408
  "path": "ui/command-palette.tsx",
373
409
  "target": "@ui/command-palette.tsx",
374
410
  "type": "registry:ui",
375
- "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. */\nexport const CommandPaletteItem = Base.Item\n\n/** The list. Takes a render function over the filtered items. */\nexport const CommandPaletteList = Base.List\n\n/** Shown when nothing matches. The words are the product's. */\nexport const CommandPaletteEmpty = Base.Empty\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 // Anchored to the viewport rather than to a trigger: a palette has no\n // trigger to point at, and the one it does have is a keystroke.\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"
411
+ "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"
376
412
  }
377
413
  ]
378
414
  },
@@ -384,7 +420,7 @@
384
420
  "dependencies": [
385
421
  "@base-ui/react",
386
422
  "class-variance-authority",
387
- "dowel-ui@^0.17.1"
423
+ "dowel-ui@^0.18.2"
388
424
  ],
389
425
  "registryDependencies": [],
390
426
  "files": [
@@ -403,7 +439,7 @@
403
439
  "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>`.",
404
440
  "dependencies": [
405
441
  "@base-ui/react",
406
- "dowel-ui@^0.17.1"
442
+ "dowel-ui@^0.18.2"
407
443
  ],
408
444
  "registryDependencies": [
409
445
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -423,7 +459,7 @@
423
459
  "title": "Copyable",
424
460
  "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.",
425
461
  "dependencies": [
426
- "dowel-ui@^0.17.1"
462
+ "dowel-ui@^0.18.2"
427
463
  ],
428
464
  "registryDependencies": [],
429
465
  "files": [
@@ -435,6 +471,52 @@
435
471
  }
436
472
  ]
437
473
  },
474
+ {
475
+ "name": "date-picker",
476
+ "type": "registry:ui",
477
+ "title": "Date-picker",
478
+ "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.",
479
+ "dependencies": [
480
+ "dowel-ui@^0.18.2"
481
+ ],
482
+ "registryDependencies": [
483
+ "https://lacodda.github.io/dowel/r/calendar.json",
484
+ "https://lacodda.github.io/dowel/r/calendar-math.json",
485
+ "https://lacodda.github.io/dowel/r/input.json",
486
+ "https://lacodda.github.io/dowel/r/popover.json"
487
+ ],
488
+ "files": [
489
+ {
490
+ "path": "ui/date-picker.tsx",
491
+ "target": "@ui/date-picker.tsx",
492
+ "type": "registry:ui",
493
+ "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-9 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"
494
+ }
495
+ ]
496
+ },
497
+ {
498
+ "name": "date-range-picker",
499
+ "type": "registry:ui",
500
+ "title": "Date-range-picker",
501
+ "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.",
502
+ "dependencies": [
503
+ "dowel-ui@^0.18.2"
504
+ ],
505
+ "registryDependencies": [
506
+ "https://lacodda.github.io/dowel/r/calendar.json",
507
+ "https://lacodda.github.io/dowel/r/calendar-math.json",
508
+ "https://lacodda.github.io/dowel/r/input.json",
509
+ "https://lacodda.github.io/dowel/r/popover.json"
510
+ ],
511
+ "files": [
512
+ {
513
+ "path": "ui/date-range-picker.tsx",
514
+ "target": "@ui/date-range-picker.tsx",
515
+ "type": "registry:ui",
516
+ "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-9 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"
517
+ }
518
+ ]
519
+ },
438
520
  {
439
521
  "name": "dialog",
440
522
  "type": "registry:ui",
@@ -443,7 +525,7 @@
443
525
  "dependencies": [
444
526
  "@base-ui/react",
445
527
  "class-variance-authority",
446
- "dowel-ui@^0.17.1"
528
+ "dowel-ui@^0.18.2"
447
529
  ],
448
530
  "registryDependencies": [],
449
531
  "files": [
@@ -463,7 +545,7 @@
463
545
  "dependencies": [
464
546
  "@base-ui/react",
465
547
  "class-variance-authority",
466
- "dowel-ui@^0.17.1"
548
+ "dowel-ui@^0.18.2"
467
549
  ],
468
550
  "registryDependencies": [],
469
551
  "files": [
@@ -481,7 +563,7 @@
481
563
  "title": "Duration-field",
482
564
  "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.",
483
565
  "dependencies": [
484
- "dowel-ui@^0.17.1"
566
+ "dowel-ui@^0.18.2"
485
567
  ],
486
568
  "registryDependencies": [
487
569
  "https://lacodda.github.io/dowel/r/input.json"
@@ -502,7 +584,7 @@
502
584
  "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.",
503
585
  "dependencies": [
504
586
  "@base-ui/react",
505
- "dowel-ui@^0.17.1"
587
+ "dowel-ui@^0.18.2"
506
588
  ],
507
589
  "registryDependencies": [],
508
590
  "files": [
@@ -520,7 +602,7 @@
520
602
  "title": "Input",
521
603
  "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.",
522
604
  "dependencies": [
523
- "dowel-ui@^0.17.1"
605
+ "dowel-ui@^0.18.2"
524
606
  ],
525
607
  "registryDependencies": [],
526
608
  "files": [
@@ -538,7 +620,7 @@
538
620
  "title": "Kbd",
539
621
  "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.",
540
622
  "dependencies": [
541
- "dowel-ui@^0.17.1"
623
+ "dowel-ui@^0.18.2"
542
624
  ],
543
625
  "registryDependencies": [],
544
626
  "files": [
@@ -558,7 +640,7 @@
558
640
  "dependencies": [
559
641
  "@base-ui/react",
560
642
  "class-variance-authority",
561
- "dowel-ui@^0.17.1"
643
+ "dowel-ui@^0.18.2"
562
644
  ],
563
645
  "registryDependencies": [],
564
646
  "files": [
@@ -577,7 +659,7 @@
577
659
  "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.",
578
660
  "dependencies": [
579
661
  "@base-ui/react",
580
- "dowel-ui@^0.17.1"
662
+ "dowel-ui@^0.18.2"
581
663
  ],
582
664
  "registryDependencies": [
583
665
  "https://lacodda.github.io/dowel/r/input.json"
@@ -598,7 +680,7 @@
598
680
  "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.",
599
681
  "dependencies": [
600
682
  "class-variance-authority",
601
- "dowel-ui@^0.17.1"
683
+ "dowel-ui@^0.18.2"
602
684
  ],
603
685
  "registryDependencies": [],
604
686
  "files": [
@@ -616,7 +698,7 @@
616
698
  "title": "Password-field",
617
699
  "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.",
618
700
  "dependencies": [
619
- "dowel-ui@^0.17.1"
701
+ "dowel-ui@^0.18.2"
620
702
  ],
621
703
  "registryDependencies": [
622
704
  "https://lacodda.github.io/dowel/r/input.json"
@@ -638,7 +720,7 @@
638
720
  "dependencies": [
639
721
  "@base-ui/react",
640
722
  "class-variance-authority",
641
- "dowel-ui@^0.17.1"
723
+ "dowel-ui@^0.18.2"
642
724
  ],
643
725
  "registryDependencies": [],
644
726
  "files": [
@@ -658,7 +740,7 @@
658
740
  "dependencies": [
659
741
  "@base-ui/react",
660
742
  "class-variance-authority",
661
- "dowel-ui@^0.17.1"
743
+ "dowel-ui@^0.18.2"
662
744
  ],
663
745
  "registryDependencies": [],
664
746
  "files": [
@@ -678,7 +760,7 @@
678
760
  "dependencies": [
679
761
  "@base-ui/react",
680
762
  "class-variance-authority",
681
- "dowel-ui@^0.17.1"
763
+ "dowel-ui@^0.18.2"
682
764
  ],
683
765
  "registryDependencies": [],
684
766
  "files": [
@@ -696,7 +778,7 @@
696
778
  "title": "Rating-scale",
697
779
  "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\".",
698
780
  "dependencies": [
699
- "dowel-ui@^0.17.1"
781
+ "dowel-ui@^0.18.2"
700
782
  ],
701
783
  "registryDependencies": [],
702
784
  "files": [
@@ -714,7 +796,7 @@
714
796
  "title": "Search-field",
715
797
  "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.",
716
798
  "dependencies": [
717
- "dowel-ui@^0.17.1"
799
+ "dowel-ui@^0.18.2"
718
800
  ],
719
801
  "registryDependencies": [
720
802
  "https://lacodda.github.io/dowel/r/input.json",
@@ -738,7 +820,7 @@
738
820
  "dependencies": [
739
821
  "@base-ui/react",
740
822
  "class-variance-authority",
741
- "dowel-ui@^0.17.1"
823
+ "dowel-ui@^0.18.2"
742
824
  ],
743
825
  "registryDependencies": [
744
826
  "https://lacodda.github.io/dowel/r/input.json"
@@ -748,7 +830,7 @@
748
830
  "path": "ui/select.tsx",
749
831
  "target": "@ui/select.tsx",
750
832
  "type": "registry:ui",
751
- "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-8 text-xs',\n md: 'h-9',\n lg: 'h-10 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-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 // 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 'data-[selected]:text-text',\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. */\nexport const SelectValue = Base.Value\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. */\nexport function SelectItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(selectItemVariants(), className)} {...props} />\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"
833
+ "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-8 text-xs',\n md: 'h-9',\n lg: 'h-10 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-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 // 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"
752
834
  }
753
835
  ]
754
836
  },
@@ -775,7 +857,7 @@
775
857
  "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\".",
776
858
  "dependencies": [
777
859
  "@base-ui/react",
778
- "dowel-ui@^0.17.1"
860
+ "dowel-ui@^0.18.2"
779
861
  ],
780
862
  "registryDependencies": [],
781
863
  "files": [
@@ -794,7 +876,7 @@
794
876
  "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.",
795
877
  "dependencies": [
796
878
  "class-variance-authority",
797
- "dowel-ui@^0.17.1"
879
+ "dowel-ui@^0.18.2"
798
880
  ],
799
881
  "registryDependencies": [],
800
882
  "files": [
@@ -813,7 +895,7 @@
813
895
  "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.",
814
896
  "dependencies": [
815
897
  "@base-ui/react",
816
- "dowel-ui@^0.17.1"
898
+ "dowel-ui@^0.18.2"
817
899
  ],
818
900
  "registryDependencies": [],
819
901
  "files": [
@@ -831,7 +913,7 @@
831
913
  "title": "Textarea",
832
914
  "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.",
833
915
  "dependencies": [
834
- "dowel-ui@^0.17.1"
916
+ "dowel-ui@^0.18.2"
835
917
  ],
836
918
  "registryDependencies": [
837
919
  "https://lacodda.github.io/dowel/r/input.json"
@@ -845,6 +927,26 @@
845
927
  }
846
928
  ]
847
929
  },
930
+ {
931
+ "name": "time-field",
932
+ "type": "registry:ui",
933
+ "title": "Time-field",
934
+ "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`.",
935
+ "dependencies": [
936
+ "dowel-ui@^0.18.2"
937
+ ],
938
+ "registryDependencies": [
939
+ "https://lacodda.github.io/dowel/r/input.json"
940
+ ],
941
+ "files": [
942
+ {
943
+ "path": "ui/time-field.tsx",
944
+ "target": "@ui/time-field.tsx",
945
+ "type": "registry:ui",
946
+ "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-9 tabular-nums', className)}\n />\n )\n}\n"
947
+ }
948
+ ]
949
+ },
848
950
  {
849
951
  "name": "toast",
850
952
  "type": "registry:ui",
@@ -853,7 +955,7 @@
853
955
  "dependencies": [
854
956
  "@base-ui/react",
855
957
  "class-variance-authority",
856
- "dowel-ui@^0.17.1"
958
+ "dowel-ui@^0.18.2"
857
959
  ],
858
960
  "registryDependencies": [],
859
961
  "files": [
@@ -873,7 +975,7 @@
873
975
  "dependencies": [
874
976
  "@base-ui/react",
875
977
  "class-variance-authority",
876
- "dowel-ui@^0.17.1"
978
+ "dowel-ui@^0.18.2"
877
979
  ],
878
980
  "registryDependencies": [],
879
981
  "files": [
@@ -881,7 +983,7 @@
881
983
  "path": "ui/tooltip.tsx",
882
984
  "target": "@ui/tooltip.tsx",
883
985
  "type": "registry:ui",
884
- "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-float',\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 'data-[side=bottom]:-top-[3px]',\n 'data-[side=top]:-bottom-[3px]',\n 'data-[side=left]:-right-[3px]',\n 'data-[side=right]:-left-[3px]',\n className,\n )}\n {...props}\n />\n )\n}\n"
986
+ "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-float',\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-[3px] data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-[3px] data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-[3px] data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-[3px] data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
885
987
  }
886
988
  ]
887
989
  },
@@ -891,7 +993,7 @@
891
993
  "title": "Truncate",
892
994
  "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.",
893
995
  "dependencies": [
894
- "dowel-ui@^0.17.1"
996
+ "dowel-ui@^0.18.2"
895
997
  ],
896
998
  "registryDependencies": [],
897
999
  "files": [
package/dist/theme.css CHANGED
@@ -423,9 +423,26 @@ body {
423
423
  -webkit-font-smoothing: antialiased;
424
424
  }
425
425
 
426
- :focus-visible {
427
- outline: 2px solid var(--accent);
428
- outline-offset: 2px;
426
+ /*
427
+ * In `@layer base`, so a component can turn it off.
428
+ *
429
+ * Unlayered, this rule has the same specificity as `focus-visible:outline-none`
430
+ * from Tailwind - both are one pseudo-class - and wins on source order alone,
431
+ * because the theme is imported before the utilities. Every component that
432
+ * draws its own focus ring got this one on top of it: the command palette's
433
+ * field had an accent outline it had explicitly opted out of, and a combobox
434
+ * with chips drew two rings, one around the box and one around the input
435
+ * inside it.
436
+ *
437
+ * A layered rule loses to any unlayered one regardless of specificity, which
438
+ * is the whole point of cascade layers - the base layer states a default and
439
+ * a component overrides it by saying so.
440
+ */
441
+ @layer base {
442
+ :focus-visible {
443
+ outline: 2px solid var(--accent);
444
+ outline-offset: 2px;
445
+ }
429
446
  }
430
447
 
431
448
  @media (prefers-reduced-motion: reduce) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowel-ui",
3
- "version": "0.17.1",
3
+ "version": "0.18.2",
4
4
  "description": "The lacodda line design system: theme tokens and React primitives, distributed as a shadcn-compatible registry.",
5
5
  "license": "MIT",
6
6
  "type": "module",