{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "dowel",
  "homepage": "https://lacodda.github.io/dowel",
  "items": [
    {
      "extends": "none",
      "name": "theme",
      "type": "registry:style",
      "title": "dowel theme",
      "description": "The token vocabulary of the lacodda line, in dark and light. A product sets `--accent-base` to its own colour and the theme derives the rest, including what colour text has to be on an accent fill.",
      "files": [
        {
          "path": "dowel/theme.css",
          "target": "~/dowel/theme.css",
          "type": "registry:file",
          "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  /* The thumb is drawn proportional to how much of the document fits on\n   * screen, so a long one collapses it to a few pixels: still visible, no\n   * longer catchable by a pointer. This is the floor below which it stops\n   * being a control. */\n  min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n  background: var(--dim);\n  background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n  background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n  display: none;\n}\n"
        }
      ],
      "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; }"
    },
    {
      "name": "accent-kasl",
      "type": "registry:file",
      "title": "kasl accent",
      "description": "lime #A9C23F - the colour of the kasl mark (ka). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/kasl.css",
          "target": "~/dowel/accents/kasl.css",
          "type": "registry:file",
          "content": "/*\n * kasl - lime #A9C23F, the colour of its mark (ka).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #A9C23F;\n}\n"
        }
      ]
    },
    {
      "name": "accent-kasl-server",
      "type": "registry:file",
      "title": "kasl-server accent",
      "description": "gold #D9A82E - the colour of the kasl-server mark (ks). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/kasl-server.css",
          "target": "~/dowel/accents/kasl-server.css",
          "type": "registry:file",
          "content": "/*\n * kasl-server - gold #D9A82E, the colour of its mark (ks).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #D9A82E;\n}\n"
        }
      ]
    },
    {
      "name": "accent-turnout",
      "type": "registry:file",
      "title": "turnout accent",
      "description": "rose #E85B72 - the colour of the turnout mark (tn). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/turnout.css",
          "target": "~/dowel/accents/turnout.css",
          "type": "registry:file",
          "content": "/*\n * turnout - rose #E85B72, the colour of its mark (tn).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #E85B72;\n}\n"
        }
      ]
    },
    {
      "name": "accent-sefy",
      "type": "registry:file",
      "title": "sefy accent",
      "description": "teal #35A8A0 - the colour of the sefy mark (se). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/sefy.css",
          "target": "~/dowel/accents/sefy.css",
          "type": "registry:file",
          "content": "/*\n * sefy - teal #35A8A0, the colour of its mark (se).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #35A8A0;\n}\n"
        }
      ]
    },
    {
      "name": "accent-atlas",
      "type": "registry:file",
      "title": "atlas accent",
      "description": "nebula #8A7DF5 - the colour of the atlas mark (at). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/atlas.css",
          "target": "~/dowel/accents/atlas.css",
          "type": "registry:file",
          "content": "/*\n * atlas - nebula #8A7DF5, the colour of its mark (at).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #8A7DF5;\n}\n"
        }
      ]
    },
    {
      "name": "accent-nitid",
      "type": "registry:file",
      "title": "nitid accent",
      "description": "cyan #3FA9D9 - the colour of the nitid mark (nd). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/nitid.css",
          "target": "~/dowel/accents/nitid.css",
          "type": "registry:file",
          "content": "/*\n * nitid - cyan #3FA9D9, the colour of its mark (nd).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #3FA9D9;\n}\n"
        }
      ]
    },
    {
      "name": "accent-midda",
      "type": "registry:file",
      "title": "midda accent",
      "description": "violet #A46BE8 - the colour of the midda mark (mi). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/midda.css",
          "target": "~/dowel/accents/midda.css",
          "type": "registry:file",
          "content": "/*\n * midda - violet #A46BE8, the colour of its mark (mi).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #A46BE8;\n}\n"
        }
      ]
    },
    {
      "name": "accent-nooma",
      "type": "registry:file",
      "title": "nooma accent",
      "description": "emerald #3FA873 - the colour of the nooma mark (nm). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/nooma.css",
          "target": "~/dowel/accents/nooma.css",
          "type": "registry:file",
          "content": "/*\n * nooma - emerald #3FA873, the colour of its mark (nm).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #3FA873;\n}\n"
        }
      ]
    },
    {
      "name": "accent-kilna",
      "type": "registry:file",
      "title": "kilna accent",
      "description": "magenta #D9569E - the colour of the kilna mark (ki). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/kilna.css",
          "target": "~/dowel/accents/kilna.css",
          "type": "registry:file",
          "content": "/*\n * kilna - magenta #D9569E, the colour of its mark (ki).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #D9569E;\n}\n"
        }
      ]
    },
    {
      "name": "accent-lyrid",
      "type": "registry:file",
      "title": "lyrid accent",
      "description": "azure #4A8FE8 - the colour of the lyrid mark (ly). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/lyrid.css",
          "target": "~/dowel/accents/lyrid.css",
          "type": "registry:file",
          "content": "/*\n * lyrid - azure #4A8FE8, the colour of its mark (ly).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #4A8FE8;\n}\n"
        }
      ]
    },
    {
      "name": "accent-efema",
      "type": "registry:file",
      "title": "efema accent",
      "description": "cobalt #5470E8 - the colour of the efema mark (ef). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/efema.css",
          "target": "~/dowel/accents/efema.css",
          "type": "registry:file",
          "content": "/*\n * efema - cobalt #5470E8, the colour of its mark (ef).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #5470E8;\n}\n"
        }
      ]
    },
    {
      "name": "accent-dowel",
      "type": "registry:file",
      "title": "dowel accent",
      "description": "signal amber #E8862D - the colour of the dowel mark (dw). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/dowel.css",
          "target": "~/dowel/accents/dowel.css",
          "type": "registry:file",
          "content": "/*\n * dowel - signal amber #E8862D, the colour of its mark (dw).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #E8862D;\n}\n"
        }
      ]
    },
    {
      "name": "accent-lyrn",
      "type": "registry:file",
      "title": "lyrn accent",
      "description": "indigo #6D7BF2 - the colour of the lyrn mark (ln). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/lyrn.css",
          "target": "~/dowel/accents/lyrn.css",
          "type": "registry:file",
          "content": "/*\n * lyrn - indigo #6D7BF2, the colour of its mark (ln).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #6D7BF2;\n}\n"
        }
      ]
    },
    {
      "name": "accent-austeris",
      "type": "registry:file",
      "title": "austeris accent",
      "description": "orchid #C25BD9 - the colour of the austeris mark (au). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/austeris.css",
          "target": "~/dowel/accents/austeris.css",
          "type": "registry:file",
          "content": "/*\n * austeris - orchid #C25BD9, the colour of its mark (au).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #C25BD9;\n}\n"
        }
      ]
    },
    {
      "name": "accent-rhapsod",
      "type": "registry:file",
      "title": "rhapsod accent",
      "description": "field green #6CB14E - the colour of the rhapsod mark (rh). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/rhapsod.css",
          "target": "~/dowel/accents/rhapsod.css",
          "type": "registry:file",
          "content": "/*\n * rhapsod - field green #6CB14E, the colour of its mark (rh).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #6CB14E;\n}\n"
        }
      ]
    },
    {
      "name": "accent-hilvan",
      "type": "registry:file",
      "title": "hilvan accent",
      "description": "crimson #D64550 - the colour of the hilvan mark (hv). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/hilvan.css",
          "target": "~/dowel/accents/hilvan.css",
          "type": "registry:file",
          "content": "/*\n * hilvan - crimson #D64550, the colour of its mark (hv).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #D64550;\n}\n"
        }
      ]
    },
    {
      "name": "accent-scheda",
      "type": "registry:file",
      "title": "scheda accent",
      "description": "terracotta (paired with sea) #D9704A - the colour of the scheda mark (sc). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/scheda.css",
          "target": "~/dowel/accents/scheda.css",
          "type": "registry:file",
          "content": "/*\n * scheda - terracotta (paired with sea) #D9704A, the colour of its mark (sc).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #D9704A;\n}\n"
        }
      ]
    },
    {
      "name": "accent-furca",
      "type": "registry:file",
      "title": "furca accent",
      "description": "terracotta (paired with olive) #D9704A - the colour of the furca mark (fr). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/furca.css",
          "target": "~/dowel/accents/furca.css",
          "type": "registry:file",
          "content": "/*\n * furca - terracotta (paired with olive) #D9704A, the colour of its mark (fr).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #D9704A;\n}\n"
        }
      ]
    },
    {
      "name": "accent-rigger",
      "type": "registry:file",
      "title": "rigger accent",
      "description": "iris (paired with lagoon) #8A62F0 - the colour of the rigger mark (rr). Import after the theme.",
      "files": [
        {
          "path": "dowel/accents/rigger.css",
          "target": "~/dowel/accents/rigger.css",
          "type": "registry:file",
          "content": "/*\n * rigger - iris (paired with lagoon) #8A62F0, the colour of its mark (rr).\n *\n * Import after the theme. Everything else - the hover shade, the soft fill,\n * the focus ring, the colour of text on an accent fill, and the trace of the\n * hue the greys carry - is derived from this one value.\n */\n:root {\n  --accent-base: #8A62F0;\n}\n"
        }
      ]
    },
    {
      "name": "action-bar",
      "type": "registry:ui",
      "title": "Action-bar",
      "description": "The strip of actions that belongs to what is on screen: Save and Cancel at the foot of a form, the bulk actions above a table, the formatting buttons over an editor. `sticky` is the point of it - a long form whose Save button is a thousand pixels below the field being edited has a Save button the reader has to go looking for.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/action-bar.tsx",
          "target": "@ui/action-bar.tsx",
          "type": "registry:ui",
          "content": "import { Toolbar as Base } from '@base-ui/react/toolbar'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * ActionBar.\n *\n * The strip of actions that belongs to what is on screen: Save and Cancel at\n * the foot of a form, the bulk actions above a table, the formatting buttons\n * over an editor. `sticky` is the point of it - a long form whose Save button\n * is a thousand pixels below the field being edited has a Save button the\n * reader has to go looking for.\n *\n * Built on Base UI's Toolbar, which supplies the part that is easy to get\n * wrong: `role=\"toolbar\"` and arrow-key navigation, so the whole bar is one\n * tab stop and the arrows move between its buttons. That matters more here\n * than anywhere else - an action bar sits at the end of a form, and without it\n * a reader tabbing out of the last field lands in a queue of five buttons\n * instead of on Save.\n *\n * The buttons are the product's own: this draws the strip, not what is in it.\n * `ActionBarButton` exists only to join a `Button` to the toolbar's keyboard\n * handling, via `render`.\n */\n\nexport const actionBarVariants = cva('flex items-center gap-2', {\n  variants: {\n    /* Where it sits, and therefore which edge grows a border.\n     *\n     * The border is on the side facing the content, so the bar reads as\n     * attached to the page rather than floating over it - and only when it is\n     * stuck, because a bar in the middle of a page needs no seam. */\n    position: {\n      bottom: 'sticky bottom-0 border-t border-line bg-bg/95 py-3 backdrop-blur',\n      top: 'sticky top-0 border-b border-line bg-bg/95 py-3 backdrop-blur',\n      /* Not stuck at all: a strip in the flow of the page. */\n      static: '',\n    },\n    justify: {\n      start: 'justify-start',\n      end: 'justify-end',\n      between: 'justify-between',\n    },\n  },\n  defaultVariants: { position: 'static', justify: 'start' },\n})\n\nexport interface ActionBarProps extends Base.Root.Props, VariantProps<typeof actionBarVariants> {}\n\n/**\n * The bar.\n *\n * `aria-label` is worth passing when a page has more than one: \"Formatting\"\n * and \"Bulk actions\" are different toolbars, and a screen reader announcing\n * \"toolbar\" twice tells the reader nothing about which one they are in.\n */\nexport function ActionBar({ position, justify, className, ...props }: ActionBarProps) {\n  return (\n    <Base.Root\n      className={cn(actionBarVariants({ position, justify }), className)}\n      // A stuck bar has to clear what scrolls under it; the theme's own\n      // stacking order is what says how high, rather than a number invented\n      // here that would disagree with every overlay in the set.\n      style={position === 'bottom' || position === 'top' ? { zIndex: 'var(--z-sticky)' } : undefined}\n      {...props}\n    />\n  )\n}\n\n/** A button in the bar. Give it the set's own `Button` through `render`, so\n * the toolbar's arrow keys reach it and the button keeps its variants. */\nexport const ActionBarButton = Base.Button\n\n/** A rule between groups of actions. Decorative, and announced as a separator\n * rather than as a control. */\nexport function ActionBarSeparator({ className, ...props }: Base.Separator.Props) {\n  return <Base.Separator className={cn('mx-1 h-5 w-px shrink-0 bg-line', className)} {...props} />\n}\n\n/** A group of related actions inside the bar - the three alignment buttons, a\n * pair of toggles. Groups the arrows walk as one. */\nexport function ActionBarGroup({ className, ...props }: Base.Group.Props) {\n  return <Base.Group className={cn('flex items-center gap-1', className)} {...props} />\n}\n\n/** Pushes what follows to the far end. A plain spacer, so a bar can put Cancel\n * beside Save and everything else to the left without a wrapper. */\nexport function ActionBarSpacer() {\n  return <div className=\"flex-1\" aria-hidden />\n}\n"
        }
      ]
    },
    {
      "name": "alert",
      "type": "registry:ui",
      "title": "Alert",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/alert.tsx",
          "target": "@ui/alert.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Alert.\n *\n * A message that stays on the screen, in the flow of the page, about the thing\n * next to it: this field could not be saved, this profile has no axes yet,\n * this export is out of date.\n *\n * Not a Toast, and the difference is worth stating because products keep\n * reaching for the wrong one. A toast is for something that just happened and\n * then goes away; an alert is for a condition that is still true and will\n * still be true after a reload. If dismissing it would be a lie, it is an\n * alert.\n *\n * Not a Banner either: a banner spans the application and speaks about the\n * whole of it. This speaks about what it sits beside.\n *\n * `role` is the caller's decision, and the default is deliberately quiet. An\n * alert that appears in response to something the reader just did should be\n * `role=\"alert\"` so it is announced; one that is simply part of the page\n * should not be, or a screen reader interrupts itself reading the furniture.\n */\n\nexport const alertVariants = cva(\n  ['flex gap-2.5 rounded-md border p-3 text-sm', '[&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:shrink-0'],\n  {\n    variants: {\n      tone: {\n        neutral: 'border-line bg-soft text-dim',\n        good: 'border-good/40 bg-good-soft text-good',\n        warn: 'border-warn/40 bg-warn-soft text-warn',\n        bad: 'border-bad/40 bg-bad-soft text-bad',\n        info: 'border-info/40 bg-info-soft text-info',\n      },\n    },\n    defaultVariants: { tone: 'neutral' },\n  },\n)\n\nexport interface AlertProps\n  // `title` on a div is the browser's tooltip and is a string; here it is the\n  // heading, and can be anything a product wants to draw.\n  extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n    VariantProps<typeof alertVariants> {\n  /** Drawn before the text. The product's own, because an icon that means\n   * \"warning\" here should be the one it means everywhere else in the product. */\n  icon?: ReactNode\n  /** The heading. Optional: a one-line alert does not need one. */\n  title?: ReactNode\n  /** Anything that goes at the end - a link to the thing that fixes it, a\n   * dismiss button the product owns. */\n  action?: ReactNode\n}\n\nexport function Alert({ tone, icon, title, action, className, children, ...props }: AlertProps) {\n  return (\n    <div className={cn(alertVariants({ tone }), className)} {...props}>\n      {icon}\n      <div className=\"min-w-0 flex-1\">\n        {title !== undefined && <div className=\"font-semibold\">{title}</div>}\n        {children !== undefined && (\n          <div className={cn('text-xs', title !== undefined && 'mt-0.5')}>{children}</div>\n        )}\n      </div>\n      {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "badge",
      "type": "registry:ui",
      "title": "Badge",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/badge.tsx",
          "target": "@ui/badge.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Badge.\n *\n * A small piece of state attached to something else: a count, a status, a\n * label. It is not a button and never was - if it can be clicked it is a Chip.\n *\n * Status colour is emphasis, never the message. A badge that means \"failed\"\n * says so in words as well, because colour alone is invisible to a reader who\n * does not separate red from green, and to anyone printing the screen.\n */\nexport const badgeVariants = cva(\n  'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs whitespace-nowrap',\n  {\n    variants: {\n      variant: {\n        outline: 'border border-line text-dim',\n        soft: 'bg-soft font-medium text-dim',\n        accent: 'bg-accent-soft font-medium text-accent',\n        good: 'bg-good-soft font-medium text-good',\n        warn: 'bg-warn-soft font-medium text-warn',\n        bad: 'bg-bad-soft font-medium text-bad',\n        info: 'bg-info-soft font-medium text-info',\n      },\n    },\n    defaultVariants: { variant: 'outline' },\n  },\n)\n\nexport interface BadgeProps\n  extends HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof badgeVariants> {}\n\nexport function Badge({ variant, className, ...props }: BadgeProps) {\n  return <span className={cn(badgeVariants({ variant }), className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "banner",
      "type": "registry:ui",
      "title": "Banner",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/banner.tsx",
          "target": "@ui/banner.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Banner.\n *\n * A strip across the top of the application, about the application: you are\n * offline, this build is a preview, your licence expires on Friday, a new\n * version is ready to install.\n *\n * The difference from Alert is what it speaks about rather than how it looks.\n * An alert belongs to what it sits beside; a banner belongs to the whole\n * screen and is true no matter which one you are on. That is also why it is\n * not dismissed by the component: whether \"you are offline\" can be dismissed\n * is the product's judgement, not the banner's, so the close button is passed\n * in like any other action.\n *\n * It is a `<div role=\"status\">` by default rather than `role=\"alert\"`: a\n * banner is usually already there when the screen loads, and an alert region\n * that fires on load interrupts whatever a screen reader was saying about the\n * page.\n */\n\nexport const bannerVariants = cva(\n  [\n    'flex w-full items-center gap-3 border-b px-4 py-2 text-sm',\n    '[&_svg]:size-4 [&_svg]:shrink-0',\n  ],\n  {\n    variants: {\n      tone: {\n        neutral: 'border-line bg-raise text-dim',\n        accent: 'border-accent/30 bg-accent-soft text-accent',\n        good: 'border-good/30 bg-good-soft text-good',\n        warn: 'border-warn/30 bg-warn-soft text-warn',\n        bad: 'border-bad/30 bg-bad-soft text-bad',\n        info: 'border-info/30 bg-info-soft text-info',\n      },\n      /** Pinned to the top of the viewport, above the application's own\n       * chrome. For the ones that must not scroll away - offline, expired. */\n      sticky: {\n        true: 'sticky top-0 [z-index:var(--z-sticky)]',\n        false: '',\n      },\n    },\n    defaultVariants: { tone: 'neutral', sticky: false },\n  },\n)\n\nexport interface BannerProps\n  extends HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof bannerVariants> {\n  /** Drawn first. The product's own icon, for the same reason as Alert's. */\n  icon?: ReactNode\n  /** Anything at the end: a button that fixes it, a link, a dismiss the\n   * product decides is allowed. */\n  action?: ReactNode\n}\n\nexport function Banner({\n  tone,\n  sticky,\n  icon,\n  action,\n  role = 'status',\n  className,\n  children,\n  ...props\n}: BannerProps) {\n  return (\n    <div className={cn(bannerVariants({ tone, sticky }), className)} role={role} {...props}>\n      {icon}\n      <div className=\"min-w-0 flex-1\">{children}</div>\n      {action !== undefined && <div className=\"shrink-0\">{action}</div>}\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "button",
      "type": "registry:ui",
      "title": "Button",
      "description": "Five variants, because that is what the line's products actually reach for: one primary action per screen, a quiet default, a soft accent for something selected, a destructive one, and an icon-only.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/button.tsx",
          "target": "@ui/button.tsx",
          "type": "registry:ui",
          "content": "import type { ButtonHTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { useRender } from '@base-ui/react/use-render'\n// `cn` comes from the package rather than being copied in beside the\n// component (ADR 0002): a helper every primitive shares should update\n// centrally, and a project installing a component already has the package for\n// the theme. shadcn's own components import it from `@/lib/utils`; that is a\n// per-project alias, and a copied file cannot know what it points at.\nimport { cn } from 'dowel-ui'\n\n/*\n * Button.\n *\n * Five variants, because that is what the line's products actually reach for:\n * one primary action per screen, a quiet default, a soft accent for something\n * selected, a destructive one, and an icon-only.\n *\n * Every colour and every size is a token. There are no `dark:` utilities and\n * no raw values - the theme swaps underneath, so the same class list is\n * correct in both themes and in every product's accent.\n */\nexport const buttonVariants = cva(\n  [\n    'inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap',\n    'font-medium transition-colors',\n    // Disabled is a state, not a colour: the button keeps its own hue and\n    // loses contact instead, which reads the same whatever the accent is.\n    'disabled:pointer-events-none disabled:opacity-50',\n    '[&_svg]:shrink-0',\n  ],\n  {\n    variants: {\n      variant: {\n        primary: 'rounded-md bg-accent font-semibold text-on-accent hover:bg-accent-2',\n        ghost: 'rounded-md border border-line text-dim hover:border-line-2 hover:text-text',\n        soft: 'rounded-md bg-accent-soft text-accent hover:bg-accent-soft/60',\n        danger: 'rounded-md text-bad hover:bg-bad-soft',\n        icon: 'rounded-md text-dim hover:bg-soft hover:text-text',\n      },\n      size: {\n        sm: 'h-7 px-2.5 text-xs',\n        md: 'h-9 px-3.5 text-sm',\n        'icon-sm': 'size-7 [&_svg]:size-3.5',\n        'icon-md': 'size-8 [&_svg]:size-4',\n      },\n    },\n    defaultVariants: { variant: 'ghost', size: 'md' },\n  },\n)\n\nexport interface ButtonProps\n  extends ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  /**\n   * Render something else with the button's clothes on - a link, most often.\n   *\n   * Takes the element itself rather than a boolean: `render={<a href=\"…\" />}`.\n   * A function is also accepted, for the rare case that needs the props\n   * before deciding what to build with them.\n   */\n  render?: useRender.RenderProp\n}\n\nexport function Button({ variant, size, render, className, type, ...props }: ButtonProps) {\n  return useRender({\n    render,\n    defaultTagName: 'button',\n    props: {\n      // A `<button>` inside a form submits it unless told otherwise, which\n      // surprises everyone once. When rendering as something else the\n      // attribute is meaningless and would land on an `<a>`, so it is only\n      // set for the element that has it - `render` is what says which.\n      ...(render === undefined && type === undefined ? { type: 'button' } : { type }),\n      className: cn(buttonVariants({ variant, size }), className),\n      ...props,\n    },\n  })\n}\n"
        }
      ]
    },
    {
      "name": "calendar-math",
      "type": "registry:ui",
      "title": "Calendar-math",
      "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.",
      "dependencies": [],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/calendar-math.tsx",
          "target": "@ui/calendar-math.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "calendar",
      "type": "registry:ui",
      "title": "Calendar",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/calendar-math.json"
      ],
      "files": [
        {
          "path": "ui/calendar.tsx",
          "target": "@ui/calendar.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "checkbox",
      "type": "registry:ui",
      "title": "Checkbox",
      "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.",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/checkbox.tsx",
          "target": "@ui/checkbox.tsx",
          "type": "registry:ui",
          "content": "import type { ReactNode } from 'react'\nimport { Checkbox as Base } from '@base-ui/react/checkbox'\nimport { CheckboxGroup as BaseGroup } from '@base-ui/react/checkbox-group'\nimport { cn } from 'dowel-ui'\n\n/*\n * Checkbox - a box, its tick, and the words next to it.\n *\n * The interesting part is the words. A checkbox on its own is a nine-pixel\n * target that says nothing; wired to a label it is the whole row, and the row\n * is what a finger and a pointer both aim at. So the label is part of the\n * component rather than something a caller remembers to add - the commonest\n * bug in a hand-rolled checkbox is a `<label>` that is next to the input\n * instead of tied to it, which looks identical and does nothing.\n *\n * The box is drawn rather than native. `appearance: none` on a real\n * `<input type=checkbox>` is the other way, and it takes the indeterminate\n * state with it: the dash is not a character the input can be told to draw.\n * Base UI renders a button with the right role and state, and the tick and\n * the dash are ours, in the accent, sized to the text next to them.\n *\n * `indeterminate` is a real state, not a third value: it says \"some of the\n * things below are checked\", and clicking still means check-all. A tri-state\n * value would make every caller handle a case that does not exist.\n */\n\nexport interface CheckboxProps {\n  /** The words next to the box. Omit only for a checkbox in a table cell,\n   * and then give `aria-label` instead. */\n  children?: ReactNode\n  checked?: boolean\n  defaultChecked?: boolean\n  onCheckedChange?: (checked: boolean) => void\n  /** Some but not all of what this stands for is checked. Draws a dash;\n   * clicking still checks everything. */\n  indeterminate?: boolean\n  /** This is the box that stands for the whole group. Inside a\n   * `CheckboxGroup` with `allValues`, its state follows the children - checked,\n   * unchecked, or the dash in between - and clicking it sets all of them.\n   * Working that out by hand is where the indeterminate state usually goes\n   * wrong, so the group does it. */\n  parent?: boolean\n  disabled?: boolean\n  required?: boolean\n  name?: string\n  value?: string\n  'aria-label'?: string\n  className?: string\n}\n\nexport function Checkbox({\n  children,\n  indeterminate = false,\n  className,\n  ...props\n}: CheckboxProps) {\n  const box = (\n    <Base.Root\n      indeterminate={indeterminate}\n      className={cn(\n        // `group` is what the two marks below hang their state off: the\n        // indeterminate one belongs to the element, not to a prop.\n        'group flex size-4 shrink-0 items-center justify-center rounded-xs border border-line-2 bg-transparent',\n        'transition-colors',\n        'hover:border-accent',\n        'data-[checked]:border-accent data-[checked]:bg-accent',\n        'data-[indeterminate]:border-accent data-[indeterminate]:bg-accent',\n        'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n        'disabled:cursor-not-allowed disabled:opacity-50',\n        // Without a label the box is the whole control, so the caller's\n        // classes land on it.\n        children === undefined && className,\n      )}\n      {...props}\n    >\n      {/* Both marks are rendered and the state picks one, rather than the\n        * `indeterminate` prop picking it. A parent checkbox never receives\n        * that prop - the group works its state out from the children and says\n        * so on the element - so choosing in JavaScript would leave the parent\n        * drawing a tick while announcing `mixed`. */}\n      <Base.Indicator\n        className=\"flex text-on-accent group-data-[indeterminate]:hidden\"\n        render={<span />}\n      >\n        <svg viewBox=\"0 0 16 16\" className=\"size-3\" aria-hidden>\n          <path\n            d=\"M3.5 8.5l3 3 6-6.5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.5\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </svg>\n      </Base.Indicator>\n      <span className=\"hidden text-on-accent group-data-[indeterminate]:flex\" aria-hidden>\n        <svg viewBox=\"0 0 16 16\" className=\"size-3\">\n          <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" />\n        </svg>\n      </span>\n    </Base.Root>\n  )\n\n  if (children === undefined) return box\n\n  return (\n    <label\n      className={cn(\n        'flex cursor-pointer items-center gap-2 text-sm text-text',\n        'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n        className,\n      )}\n    >\n      {box}\n      {children}\n    </label>\n  )\n}\n\n/*\n * A group of checkboxes that share a name and a value.\n *\n * It exists for the parent checkbox: given `allValues`, Base UI works out\n * whether the parent is checked, unchecked or indeterminate, and clicking it\n * sets all of them. Doing that by hand is where the indeterminate state\n * usually goes wrong.\n */\nexport interface CheckboxGroupProps {\n  children: ReactNode\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (value: string[]) => void\n  /** Every value in the group, which is what makes a parent checkbox work. */\n  allValues?: string[]\n  disabled?: boolean\n  className?: string\n}\n\nexport function CheckboxGroup({ children, className, ...props }: CheckboxGroupProps) {\n  return (\n    <BaseGroup className={cn('flex flex-col gap-2', className)} {...props}>\n      {children}\n    </BaseGroup>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "chip",
      "type": "registry:ui",
      "title": "Chip",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/chip.tsx",
          "target": "@ui/chip.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes, MouseEvent } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Chip.\n *\n * A badge you can act on: a filter that can be removed, a tag with a count, a\n * selected value in a field. The difference from a Badge is entirely about\n * whether something happens when you click it - and if something does, that\n * part is a real `<button>` with a real label, not a decorative cross.\n *\n * `onRemove` is the reason this exists separately. Every product wrote the\n * same removable tag and every one of them made the cross a `<span>`, which\n * the keyboard cannot reach and a screen reader does not announce.\n */\nexport const chipVariants = cva(\n  'inline-flex items-center gap-1 rounded-full border py-0.5 pl-2.5 text-xs whitespace-nowrap transition-colors',\n  {\n    variants: {\n      variant: {\n        outline: 'border-line text-dim',\n        accent: 'border-transparent bg-accent-soft text-accent',\n        soft: 'border-transparent bg-soft text-dim',\n      },\n      /** Padding on the right depends on whether a remove button sits there. */\n      removable: {\n        true: 'pr-1',\n        false: 'pr-2.5',\n      },\n    },\n    defaultVariants: { variant: 'outline', removable: false },\n  },\n)\n\nexport interface ChipProps\n  extends Omit<HTMLAttributes<HTMLSpanElement>, 'onSelect'>,\n    Omit<VariantProps<typeof chipVariants>, 'removable'> {\n  /** Shown after the label, for a count. */\n  count?: number\n}\n\n/*\n * Removable, or not - and if removable, named.\n *\n * The two props travel together as a union rather than as two optionals, so\n * the type says what the component means: a remove button exists only when\n * there is a word for it. There is no default word on purpose. A string the\n * component invents is a string the product cannot translate, and it would\n * ship in English to every reader who does not read English.\n */\ntype Removable =\n  | { onRemove: () => void; removeLabel: string }\n  | { onRemove?: never; removeLabel?: never }\n\nexport function Chip({\n  variant,\n  count,\n  onRemove,\n  removeLabel,\n  className,\n  children,\n  ...props\n}: ChipProps & Removable) {\n  return (\n    <span className={cn(chipVariants({ variant, removable: Boolean(onRemove) }), className)} {...props}>\n      {children}\n\n      {count !== undefined && <span className=\"text-faint tabular-nums\">{count}</span>}\n\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={removeLabel}\n          onClick={(event: MouseEvent<HTMLButtonElement>) => {\n            // A chip is often inside something else that is also clickable.\n            event.stopPropagation()\n            onRemove()\n          }}\n          className={cn(\n            'grid size-4 place-items-center rounded-full text-faint transition-colors',\n            'hover:bg-line hover:text-text',\n            'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n          )}\n        >\n          <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n            <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n          </svg>\n        </button>\n      )}\n    </span>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "color-field",
      "type": "registry:ui",
      "title": "Color-field",
      "description": "Picking a colour for something the product stores: a tag, a project, a calendar. Note what that is *not* - it is not choosing the appearance of the interface. The theme decides that, from one accent, and a field that let a reader repaint the chrome would undo the argument the whole system rests on.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/color-field.tsx",
          "target": "@ui/color-field.tsx",
          "type": "registry:ui",
          "content": "import { useId, useState, type ReactNode } from 'react'\nimport { cn, lineProducts } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * ColorField.\n *\n * Picking a colour for something the product stores: a tag, a project, a\n * calendar. Note what that is *not* - it is not choosing the appearance of the\n * interface. The theme decides that, from one accent, and a field that let a\n * reader repaint the chrome would undo the argument the whole system rests on.\n *\n * So the palette comes first and the free field second. The swatches are the\n * line's own fourteen accents plus the four semantic tokens, which is the\n * vocabulary a product already speaks; a value picked there is a *name*\n * (`kilna`, `good`), not a hex, and it keeps meaning the right thing when the\n * theme changes underneath it.\n *\n * Free hex is available because the owner asked for it, and it is worth\n * saying why that does not contradict \"no raw colours in components\": the rule\n * is about a component hardcoding its own appearance. A colour the reader\n * chose for their own tag is the product's *data* - the same kind of thing as\n * the tag's name - and data does not come from a token vocabulary.\n *\n * The swatch is a real `<input type=\"radio\">` per colour, in a `radiogroup`.\n * That is what makes the arrows walk the palette, what makes the choice\n * announce itself, and what makes the whole group one tab stop.\n */\n\n/** The four semantic colours, which mean the same thing in every product. */\nconst SEMANTIC = ['good', 'warn', 'bad', 'info'] as const\n\n/** Whether a string is a colour this field would accept as free input. Six\n * digits or three, with the hash - the two forms a reader actually types. */\nexport function isHexColor(value: string): boolean {\n  return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)\n}\n\nexport interface ColorFieldProps {\n  /** The chosen colour: a token name, or a hex string. */\n  value: string\n  onValueChange: (value: string) => void\n  /** Whether the reader may type a colour of their own. */\n  allowCustom?: boolean\n  /** Names the palette for a screen reader. */\n  'aria-label'?: string\n  'aria-labelledby'?: string\n  /** How a swatch is named. Takes the colour's name, so the product can say\n   * \"Lime\" in its own language rather than shipping ours. */\n  swatchLabel?: (name: string) => string\n  /** The label on the free-entry box, when `allowCustom` is on. */\n  customLabel?: ReactNode\n  disabled?: boolean\n  className?: string\n}\n\nexport function ColorField({\n  value,\n  onValueChange,\n  allowCustom,\n  swatchLabel,\n  customLabel,\n  disabled,\n  className,\n  ...aria\n}: ColorFieldProps) {\n  const name = useId()\n  const customId = useId()\n\n  /* What is in the free box while it is being typed.\n   *\n   * It is not `value`: half of `#3fa9` is not a colour, and pushing every\n   * keystroke up would either reject the reader mid-word or paint the product\n   * a colour they were passing through. The box commits when what is in it is\n   * a colour. */\n  const [draft, setDraft] = useState(isHexColor(value) ? value : '')\n\n  const swatches = [\n    ...lineProducts.map((product) => ({ token: product.name, css: product.accent })),\n    ...SEMANTIC.map((token) => ({ token, css: `var(--${token})` })),\n  ]\n\n  return (\n    <div className={cn('flex flex-col gap-2', disabled && 'pointer-events-none opacity-50', className)}>\n      <div role=\"radiogroup\" {...aria} className=\"flex flex-wrap gap-1.5\">\n        {swatches.map((swatch) => {\n          const chosen = value === swatch.token\n          return (\n            <label\n              key={swatch.token}\n              className={cn(\n                'relative grid size-7 cursor-pointer place-items-center rounded-md border transition-colors',\n                chosen ? 'border-accent' : 'border-line hover:border-dim',\n                // The ring has to be on the label: the input itself is hidden,\n                // so `focus-visible` on it would never be seen.\n                'focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-accent',\n              )}\n            >\n              <input\n                type=\"radio\"\n                name={name}\n                value={swatch.token}\n                checked={chosen}\n                disabled={disabled}\n                onChange={() => onValueChange(swatch.token)}\n                className=\"sr-only\"\n              />\n              <span\n                aria-hidden\n                className=\"size-5 rounded\"\n                /* The one place a colour is set from a value rather than a\n                 * class, because the value *is* the colour - a palette that\n                 * could not show its own colours would be a list of words. */\n                style={{ background: swatch.css }}\n              />\n              <span className=\"sr-only\">{swatchLabel?.(swatch.token) ?? swatch.token}</span>\n            </label>\n          )\n        })}\n      </div>\n\n      {allowCustom && (\n        <div className=\"flex items-center gap-2\">\n          <label htmlFor={customId} className=\"text-xs text-dim\">\n            {customLabel}\n          </label>\n          <input\n            id={customId}\n            value={draft}\n            disabled={disabled}\n            /* No placeholder of its own.\n             *\n             * The obvious one is a specimen hex, and the lint rule that\n             * forbids raw colours flags it - correctly, by the letter. Rather\n             * than widen a gate that catches real violations for the sake of\n             * one string, the format is shown by the monospace box and named\n             * by `customLabel`, which is the product's word anyway. */\n            spellCheck={false}\n            onChange={(event) => {\n              const next = event.target.value\n              setDraft(next)\n              // Committed only once it is a colour. Anything else is a word in\n              // progress, and the product should not be repainted by one.\n              if (isHexColor(next)) onValueChange(next)\n            }}\n            className={cn(fieldClasses, 'h-8 w-28 font-mono text-xs')}\n          />\n          <span\n            aria-hidden\n            className=\"size-5 shrink-0 rounded border border-line\"\n            style={{ background: isHexColor(draft) ? draft : 'transparent' }}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "combobox",
      "type": "registry:ui",
      "title": "Combobox",
      "description": "A Select you can type in. The list narrows as the query is typed, which is the only difference that matters and the reason to reach for this one: a Select stops being usable somewhere around thirty options, and a country picker or a tag field is well past that.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/select.json"
      ],
      "files": [
        {
          "path": "ui/combobox.tsx",
          "target": "@ui/combobox.tsx",
          "type": "registry:ui",
          "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  /**\n   * What to line the list up with. Defaults to the input that owns it.\n   *\n   * Pass the `ComboboxChips` box when there is one: with chips, the input is\n   * only as wide as what has been typed - an empty one measured 214px inside\n   * a 288px field - so a list anchored to it hangs short of the box a reader\n   * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n   * how the mismatch is visible from outside.\n   */\n  anchor?: Base.Positioner.Props['anchor']\n  /** Where to portal to. Defaults to the document body. */\n  container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n  size,\n  side,\n  align,\n  sideOffset = 4,\n  anchor,\n  container,\n  className,\n  children,\n  ...props\n}: ComboboxPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Positioner\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        anchor={anchor}\n        className=\"[z-index:var(--z-menu)]\"\n      >\n        <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n          {children}\n        </Base.Popup>\n      </Base.Positioner>\n    </Base.Portal>\n  )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n  return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n  return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n  return (\n    <Base.Chip\n      className={cn(\n        'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n        'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n  return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n  return (\n    <Base.GroupLabel\n      className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "command-palette",
      "type": "registry:ui",
      "title": "Command-palette",
      "description": "One box that finds anything: the shortcut opens it, typing narrows a list, Enter runs what is highlighted.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/combobox.json",
        "https://lacodda.github.io/dowel/r/kbd.json"
      ],
      "files": [
        {
          "path": "ui/command-palette.tsx",
          "target": "@ui/command-palette.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "confirm-dialog",
      "type": "registry:ui",
      "title": "Confirm-dialog",
      "description": "The dialog for a choice that cannot be taken back - deleting, discarding, revoking. It looks almost exactly like Dialog, and that is deliberate: the difference is not clothes, it is what the popup is allowed to do.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/confirm-dialog.tsx",
          "target": "@ui/confirm-dialog.tsx",
          "type": "registry:ui",
          "content": "import { AlertDialog as Base } from '@base-ui/react/alert-dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * ConfirmDialog.\n *\n * The dialog for a choice that cannot be taken back - deleting, discarding,\n * revoking. It looks almost exactly like Dialog, and that is deliberate: the\n * difference is not clothes, it is what the popup is allowed to do.\n *\n * A Dialog is dismissed by clicking away, because a Dialog is a place the user\n * wandered into. This one is not: Base UI's `AlertDialog` announces itself as\n * `role=\"alertdialog\"`, which tells a screen reader the popup is interrupting\n * rather than presenting, and it forces `modal` and `disablePointerDismissal`\n * on - `AlertDialog.Root` omits both from Dialog's props, so there is nothing\n * to turn off. A press outside does nothing at all.\n *\n * `Escape` still closes it, and that is deliberate rather than an oversight: a\n * popup with no keyboard way out is a trap. The difference is between a\n * deliberate keypress and clicking absentmindedly beside a dialog.\n *\n * So the rule for choosing between the two is not how important the content\n * feels. It is whether dismissing it by a stray click would be a loss.\n */\n\nexport const confirmDialogPopupVariants = cva(\n  [\n    'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n    'rounded-xl border border-line bg-raise p-5 text-text shadow-float',\n    'focus-visible:outline-none',\n    // The enter and the leave. `duration-*` reads the token directly because\n    // Tailwind's own utility takes a literal number.\n    '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n    'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n    'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n  ],\n  {\n    variants: {\n      size: {\n        sm: 'w-[min(22rem,calc(100vw-2rem))]',\n        md: 'w-[min(26rem,calc(100vw-2rem))]',\n        lg: 'w-[min(34rem,calc(100vw-2rem))]',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `ConfirmDialogTrigger`. */\nexport const ConfirmDialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const ConfirmDialogTrigger = Base.Trigger\n\n/** What closes it - and the only thing that does, which is why a confirm\n * dialog with no `ConfirmDialogClose` inside it is a trap. */\nexport const ConfirmDialogClose = Base.Close\n\n/** The scrim. Darker than the Dialog's, because what is behind it is not just\n * out of reach for a moment - it is waiting on an answer. */\nexport function ConfirmDialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n  return (\n    <Base.Backdrop\n      className={cn(\n        'fixed inset-0 bg-black/60 backdrop-blur-[2px]',\n        '[z-index:var(--z-overlay)]',\n        '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n        'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport interface ConfirmDialogPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof confirmDialogPopupVariants> {\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * the popup from being clipped by an ancestor. Pass an element to put it\n   * somewhere else - inside an overlay that is already open, or into a\n   * container being screenshotted. */\n  container?: Base.Portal.Props['container']\n  /** Whether to draw the scrim. On by default, and it should stay on for\n   * anything a person actually uses: the dim is what says the page behind is\n   * out of reach. Turn it off where the popup is shown alongside other things\n   * on purpose - a component gallery, a screenshot - because a scrim is\n   * `position: fixed` and covers everything, not only its own container. */\n  backdrop?: boolean\n}\n\n/** The dialog itself, announced as `alertdialog`. Portalled, so it is not\n * clipped by whatever it was opened from. */\nexport function ConfirmDialogPopup({\n  size,\n  container,\n  backdrop = true,\n  className,\n  children,\n  ...props\n}: ConfirmDialogPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      {backdrop && <ConfirmDialogBackdrop />}\n      <Base.Popup\n        className={cn(\n          confirmDialogPopupVariants({ size }),\n          '[z-index:var(--z-modal)]',\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </Base.Popup>\n    </Base.Portal>\n  )\n}\n\n/** The question. Base UI points the popup's `aria-labelledby` at it, so this\n * is what a screen reader reads out when the dialog interrupts. */\nexport function ConfirmDialogTitle({ className, ...props }: Base.Title.Props) {\n  return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** What the answer costs, and the popup's `aria-describedby`. On this dialog\n * it is close to required: the title asks, the description says what happens. */\nexport function ConfirmDialogDescription({ className, ...props }: Base.Description.Props) {\n  return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the two answers go. Right-aligned, because the choice belongs where\n * the eye leaves the sentence. */\nexport function ConfirmDialogActions({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn('mt-5 flex justify-end gap-2', className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "context-menu",
      "type": "registry:ui",
      "title": "Context-menu",
      "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>`.",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/menu.json"
      ],
      "files": [
        {
          "path": "ui/context-menu.tsx",
          "target": "@ui/context-menu.tsx",
          "type": "registry:ui",
          "content": "import { ContextMenu as Base } from '@base-ui/react/context-menu'\nimport { menuItemVariants, menuPopupVariants } from './menu'\nimport { cn } from 'dowel-ui'\n\n/*\n * ContextMenu.\n *\n * The same list of actions as Menu, opened the other way round: by right\n * click, or by a long press on a touch screen, over an *area* rather than\n * from a button. So the trigger is not a control - it is the region the menu\n * belongs to, a row, a canvas, a file tile - and it renders a `<div>`.\n *\n * That difference is the whole component. Everything below the Root is Menu's\n * own: Base UI re-exports the Portal, Positioner, Popup, Item and the rest\n * from the menu package, so the popup that opens here is literally the same\n * popup, with the same keyboard, the same type-ahead and the same submenus.\n *\n * Which is why the clothes are imported rather than copied. Two `cva` calls\n * that started identical drift within a release - one gets a padding fix, the\n * other does not - and then a product has two menus that are almost the same,\n * which is worse than two that differ on purpose.\n *\n * What Base UI does *not* give this one is `openOnHover`, `modal` or a\n * `handle`: a context menu is opened by a gesture over an anchor point, not\n * by a trigger element it can be attached to from elsewhere.\n */\n\n/** The root. It positions against the pointer, so there is nothing to anchor\n * and nothing to control but `onOpenChange`. */\nexport const ContextMenu = Base.Root\n\n/** The area that opens it. A `<div>`, not a button: give it `render` to make\n * it the row or the canvas it belongs to rather than a wrapper. */\nexport const ContextMenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const ContextMenuGroup = Base.Group\n\n/** A submenu, opened from a `ContextMenuSubTrigger` inside the parent. */\nexport const ContextMenuSub = Base.SubmenuRoot\n\nexport interface ContextMenuPopupProps extends Base.Popup.Props {\n  /** How wide the popup starts. The same three as Menu's. */\n  size?: 'sm' | 'md' | 'lg'\n  /** Where to portal to. Defaults to the document body, which keeps the menu\n   * from being clipped by the very row it was opened over. */\n  container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled, and positioned against the point that was clicked\n * rather than against an element. */\nexport function ContextMenuPopup({\n  size,\n  container,\n  className,\n  children,\n  ...props\n}: ContextMenuPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Positioner className=\"[z-index:var(--z-menu)]\">\n        <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n          {children}\n        </Base.Popup>\n      </Base.Positioner>\n    </Base.Portal>\n  )\n}\n\nexport interface ContextMenuItemProps extends Base.Item.Props {\n  /** `danger` draws the destructive one apart, in the colour of something\n   * that cannot be undone. */\n  tone?: 'default' | 'danger'\n}\n\n/** An action. */\nexport function ContextMenuItem({ tone, className, ...props }: ContextMenuItemProps) {\n  return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function ContextMenuSubTrigger({ tone, className, ...props }: ContextMenuItemProps) {\n  return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's. */\nexport function ContextMenuCheckboxItem({ tone, className, ...props }: ContextMenuItemProps) {\n  return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const ContextMenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such. */\nexport function ContextMenuSeparator({ className, ...props }: Base.Separator.Props) {\n  return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ContextMenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n  return (\n    <Base.GroupLabel\n      className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "copyable",
      "type": "registry:ui",
      "title": "Copyable",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/copyable.tsx",
          "target": "@ui/copyable.tsx",
          "type": "registry:ui",
          "content": "import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Copyable.\n *\n * Any text that someone will eventually want to copy - an id, a path, a hash,\n * a token - copied with one click. The rule comes from nitid: if a value is\n * worth showing, it is worth being able to take away, and selecting a\n * monospaced id by hand is a small daily tax.\n *\n * Two things this gets right that the hand-written version usually does not.\n * It is a `<button>`, so the keyboard can reach it and a screen reader says\n * what it does. And the confirmation is announced, not only drawn: a tick that\n * appears silently tells a sighted user it worked and tells nobody else.\n *\n * The clipboard can refuse - it needs a secure context and, in some browsers,\n * a permission. A refusal is reported rather than swallowed, because a button\n * that looks like it worked and did not is worse than one that says it failed.\n */\nexport interface CopyableProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n  /** What lands on the clipboard. Defaults to the visible text. */\n  value?: string\n  /** The visible text. */\n  children: string\n  /** What the button is called, for a screen reader. Required, and\n   * deliberately without a default: a string the component invents is a\n   * string the product cannot translate, and it would ship in English to\n   * every reader who does not read English. */\n  label: string\n  /** What is announced after a successful copy. Required for the same\n   * reason. */\n  copiedLabel: string\n  /** Told what happened, for a product that wants its own toast. */\n  onCopy?: (ok: boolean) => void\n}\n\nexport function Copyable({\n  value,\n  children,\n  label,\n  copiedLabel,\n  onCopy,\n  className,\n  ...props\n}: CopyableProps) {\n  const [copied, setCopied] = useState(false)\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  // A component that sets state on a timer has to stop when it goes away, or\n  // it wakes up in a tree that no longer exists.\n  useEffect(() => () => clearTimeout(timer.current), [])\n\n  const copy = useCallback(async () => {\n    try {\n      await navigator.clipboard.writeText(value ?? children)\n      setCopied(true)\n      onCopy?.(true)\n      clearTimeout(timer.current)\n      timer.current = setTimeout(() => setCopied(false), 1600)\n    } catch {\n      // No secure context, or permission refused. Say so rather than pretend.\n      onCopy?.(false)\n    }\n  }, [value, children, onCopy])\n\n  return (\n    <button\n      type=\"button\"\n      onClick={copy}\n      aria-label={copied ? copiedLabel : label}\n      className={cn(\n        'group inline-flex max-w-full items-center gap-1.5 rounded-sm px-1 py-0.5 text-left',\n        'font-mono text-xs text-dim transition-colors',\n        'hover:bg-soft hover:text-text',\n        'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"truncate\">{children}</span>\n\n      <span className={cn('shrink-0 transition-colors', copied ? 'text-good' : 'text-faint')} aria-hidden>\n        {copied ? <Tick /> : <Clipboard />}\n      </span>\n\n      {/* Drawn confirmation is invisible to a screen reader; this is the part\n          that actually says it worked. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {copied ? copiedLabel : ''}\n      </span>\n    </button>\n  )\n}\n\nfunction Clipboard() {\n  return (\n    <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n      <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n      <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n    </svg>\n  )\n}\n\nfunction Tick() {\n  return (\n    <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n      <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "date-picker",
      "type": "registry:ui",
      "title": "Date-picker",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/calendar.json",
        "https://lacodda.github.io/dowel/r/calendar-math.json",
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/popover.json"
      ],
      "files": [
        {
          "path": "ui/date-picker.tsx",
          "target": "@ui/date-picker.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "date-range-picker",
      "type": "registry:ui",
      "title": "Date-range-picker",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/calendar.json",
        "https://lacodda.github.io/dowel/r/calendar-math.json",
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/popover.json"
      ],
      "files": [
        {
          "path": "ui/date-range-picker.tsx",
          "target": "@ui/date-range-picker.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "dialog",
      "type": "registry:ui",
      "title": "Dialog",
      "description": "The parts are exposed rather than wrapped in one component with `title` and `footer` props. The products that did it the other way ended up passing `footer={<>…</>}` within a week, which is a slot with extra steps - and a dialog that owns its own close button owns a word for it, which is a word the product cannot translate.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/dialog.tsx",
          "target": "@ui/dialog.tsx",
          "type": "registry:ui",
          "content": "import { Dialog as Base } from '@base-ui/react/dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Dialog.\n *\n * The parts are exposed rather than wrapped in one component with `title` and\n * `footer` props. The products that did it the other way ended up passing\n * `footer={<>…</>}` within a week, which is a slot with extra steps - and a\n * dialog that owns its own close button owns a word for it, which is a word\n * the product cannot translate.\n *\n * Behaviour is Base UI's: the focus trap, the return of focus to whatever\n * opened it, `Escape`, the scroll lock, and the `aria-labelledby` that ties\n * the popup to its own title. None of that is worth rewriting, and all of it\n * is wrong in the ways nobody notices until someone is navigating by keyboard.\n *\n * What is ours is the clothes, and the enter and leave: the popup arrives with\n * `data-open` and leaves with `data-closed`, both of which Base UI sets, so\n * the animation is a class list rather than a state machine.\n */\n\nexport const dialogPopupVariants = cva(\n  [\n    'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n    'rounded-xl border border-line bg-raise p-5 text-text shadow-float',\n    'focus-visible:outline-none',\n    // The width was capped from the start and the height was not, so a dialog\n    // with more in it than the window is tall centred itself and hung off both\n    // ends - the top out of reach above the viewport, the buttons below it.\n    // Found on a release editor in kilna, which is exactly the shape that does\n    // it: half a dozen fields and a row of actions.\n    'max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain',\n    // The enter and the leave. `duration-*` reads the token directly because\n    // Tailwind's own utility takes a literal number.\n    '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n    'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n    'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n  ],\n  {\n    variants: {\n      size: {\n        sm: 'w-[min(24rem,calc(100vw-2rem))]',\n        md: 'w-[min(28rem,calc(100vw-2rem))]',\n        lg: 'w-[min(40rem,calc(100vw-2rem))]',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `Dialog.Trigger`. */\nexport const Dialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DialogTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DialogClose = Base.Close\n\n/** The scrim. Dark because a modal is a modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n  return (\n    <Base.Backdrop\n      className={cn(\n        'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n        '[z-index:var(--z-overlay)]',\n        '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n        'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport interface DialogPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof dialogPopupVariants> {\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * the popup from being clipped by whatever it was opened from. Pass an\n   * element to put it somewhere else - inside a dialog that is already open,\n   * or into a container being screenshotted. */\n  container?: Base.Portal.Props['container']\n  /** Whether to draw the scrim. On by default, and it should stay on for\n   * anything a person actually uses: the dim is what says the page behind is\n   * out of reach. Turn it off where the popup is shown alongside other things\n   * on purpose - a component gallery, a screenshot - because a scrim is\n   * `position: fixed` and covers everything, not only its own container. */\n  backdrop?: boolean\n}\n\n/** The dialog itself. Portalled, so it is not clipped by whatever it was\n * opened from. */\nexport function DialogPopup({\n  size,\n  container,\n  backdrop = true,\n  className,\n  children,\n  ...props\n}: DialogPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      {backdrop && <DialogBackdrop />}\n      <Base.Popup\n        className={cn(dialogPopupVariants({ size }), '[z-index:var(--z-modal)]', className)}\n        {...props}\n      >\n        {children}\n      </Base.Popup>\n    </Base.Portal>\n  )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * dialog with one is named for a screen reader without anyone arranging it. */\nexport function DialogTitle({ className, ...props }: Base.Title.Props) {\n  return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DialogDescription({ className, ...props }: Base.Description.Props) {\n  return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the actions go. Right-aligned, because the primary action of a\n * dialog belongs where the eye leaves the sentence. */\nexport function DialogActions({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn('mt-5 flex justify-end gap-2', className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "drawer",
      "type": "registry:ui",
      "title": "Drawer",
      "description": "A panel that slides in from an edge and holds the screen while it is there - a filter sheet, a detail pane, a form too long to centre. Modal like Dialog, placed like nothing else.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/drawer.tsx",
          "target": "@ui/drawer.tsx",
          "type": "registry:ui",
          "content": "import { Drawer as Base } from '@base-ui/react/drawer'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drawer.\n *\n * A panel that slides in from an edge and holds the screen while it is there -\n * a filter sheet, a detail pane, a form too long to centre. Modal like Dialog,\n * placed like nothing else.\n *\n * Base UI positions none of it. Unlike Popover there is no Positioner and no\n * anchor to measure against; the edge it comes from is entirely CSS, which is\n * what the `side` variant is. It drives three things at once and they have to\n * agree: where the Viewport pushes the panel, which border it grows against,\n * and which way it is translated while opening and closing.\n *\n * `swipeDirection` is the other half and cannot be inferred here: it lives on\n * the root, while `side` lives on the popup, so the two are set together by\n * hand - `side=\"right\"` with `swipeDirection=\"right\"`, `side=\"bottom\"` with\n * `down`, which is Base UI's default. Left unmatched, the drawer slides in\n * from one edge and is flicked away towards another.\n *\n * The transitions key off `data-starting-style` and `data-ending-style` rather\n * than `data-closed`. That is Base UI's own convention for the drawer, and the\n * reason is that a drawer is dragged as well as animated: the popup carries a\n * live `--drawer-swipe-movement-*` while a finger is on it, and the transform\n * has to compose with that rather than replace it.\n */\n\n/** The Viewport: fixed to the whole window, pushing the popup to one edge.\n *\n * It is separate from the popup because a drawer that is its own positioner\n * cannot be scrolled independently of where it sits - and the panel needs to\n * scroll while the edge it is pinned to does not move. */\nconst drawerViewportVariants = cva('fixed inset-0 flex', {\n  variants: {\n    side: {\n      right: 'justify-end',\n      left: 'justify-start',\n      bottom: 'items-end',\n    },\n  },\n  defaultVariants: { side: 'right' },\n})\n\nexport const drawerPopupVariants = cva(\n  [\n    'flex flex-col overflow-y-auto overscroll-contain',\n    'border-line bg-raise p-5 text-text shadow-float',\n    'focus-visible:outline-none',\n    // Composed with the live swipe offset rather than replacing it, so a\n    // half-dragged drawer animates from where the finger left it.\n    '[transition:transform_var(--duration-base)_var(--ease-out)]',\n    'will-change-transform',\n  ],\n  {\n    variants: {\n      /* Where it comes from. The three the products of the line reach for:\n       * a right-hand pane, its mirror, and a bottom sheet. */\n      side: {\n        right: [\n          'h-full w-[min(24rem,calc(100vw-3rem))] border-l',\n          '[transform:translateX(var(--drawer-swipe-movement-x))]',\n          'data-[starting-style]:[transform:translateX(100%)]',\n          'data-[ending-style]:[transform:translateX(100%)]',\n        ],\n        left: [\n          'h-full w-[min(24rem,calc(100vw-3rem))] border-r',\n          '[transform:translateX(var(--drawer-swipe-movement-x))]',\n          'data-[starting-style]:[transform:translateX(-100%)]',\n          'data-[ending-style]:[transform:translateX(-100%)]',\n        ],\n        bottom: [\n          'max-h-[80vh] w-full rounded-t-xl border-t',\n          '[transform:translateY(var(--drawer-swipe-movement-y))]',\n          'data-[starting-style]:[transform:translateY(100%)]',\n          'data-[ending-style]:[transform:translateY(100%)]',\n        ],\n      },\n    },\n    defaultVariants: { side: 'right' },\n  },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `DrawerTrigger`. */\nexport const Drawer = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DrawerTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DrawerClose = Base.Close\n\n/** The scrim. Dark because a drawer is modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DrawerBackdrop({ className, ...props }: Base.Backdrop.Props) {\n  return (\n    <Base.Backdrop\n      className={cn(\n        'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n        '[z-index:var(--z-overlay)]',\n        '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n        'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport interface DrawerPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof drawerPopupVariants> {\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * the popup from being clipped by an ancestor. Pass an element to put it\n   * somewhere else - inside an overlay that is already open, or into a\n   * container being screenshotted. */\n  container?: Base.Portal.Props['container']\n  /** Whether to draw the scrim. On by default, and it should stay on for\n   * anything a person actually uses: the dim is what says the page behind is\n   * out of reach. Turn it off where the popup is shown alongside other things\n   * on purpose - a component gallery, a screenshot - because a scrim is\n   * `position: fixed` and covers everything, not only its own container. */\n  backdrop?: boolean\n}\n\n/** The panel. Portalled, and wrapped in its own viewport so the edge it is\n * pinned to holds still while the contents scroll. */\nexport function DrawerPopup({\n  container,\n  backdrop = true,\n  side,\n  className,\n  children,\n  ...props\n}: DrawerPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      {backdrop && <DrawerBackdrop />}\n      <div className={cn(drawerViewportVariants({ side }), '[z-index:var(--z-modal)]')}>\n        <Base.Viewport className=\"flex w-full\">\n          <Base.Popup\n            className={cn(drawerPopupVariants({ side }), className)}\n            {...props}\n          >\n            {children}\n          </Base.Popup>\n        </Base.Viewport>\n      </div>\n    </Base.Portal>\n  )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * drawer with one is named for a screen reader without anyone arranging it. */\nexport function DrawerTitle({ className, ...props }: Base.Title.Props) {\n  return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DrawerDescription({ className, ...props }: Base.Description.Props) {\n  return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n\n/** Where the actions go. Pushed to the bottom of the panel rather than sitting\n * under the content, because a drawer is tall and its buttons should not\n * wander up the page when there is little in it. */\nexport function DrawerActions({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {\n  return <div className={cn('mt-auto flex justify-end gap-2 pt-5', className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "duration-field",
      "type": "registry:ui",
      "title": "Duration-field",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/duration-field.tsx",
          "target": "@ui/duration-field.tsx",
          "type": "registry:ui",
          "content": "import { useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * DurationField - a length of time, typed the way people say it.\n *\n * The alternative is what products keep building: two number boxes labelled\n * \"hours\" and \"minutes\", which means two tab stops, two validations, and a\n * reader who has to divide 90 minutes in their head before typing. Here they\n * write `1h 30m`, or `90m`, or `1.5h`, and it means the same thing.\n *\n * The value is **minutes**, a plain number. Not a string, not a Duration\n * object: the products of this line store durations as minutes already, and\n * a field whose value has to be parsed by its caller has moved the problem\n * rather than solved it.\n *\n * Parsing is deliberately generous and formatting is strict. Anything a\n * person plausibly types is accepted - `2h`, `2 h`, `2:30`, `150`, `2h30`,\n * with or without spaces - and what comes back on blur is always the one\n * canonical spelling. That asymmetry is the whole design: being strict on\n * input means rejecting people, being loose on output means the column of\n * values never lines up.\n *\n * Empty is `null`, like NumberField: \"no duration\" and \"zero minutes\" are\n * different answers, and a task with no estimate is not a task estimated at\n * nothing.\n */\n\n/** Minutes from whatever was typed, or `null` for empty, or `undefined` when\n * it cannot be read as a duration at all.\n *\n * Exported because the parsing *is* the component - a test that goes through\n * the DOM checks React's state handling, and what needs checking is this. */\nexport function parseDuration(text: string): number | null | undefined {\n  const input = text.trim().toLowerCase()\n  if (input === '') return null\n\n  // `2:30` - the clock spelling, which is unambiguous and worth taking.\n  const clock = /^(\\d+):([0-5]?\\d)$/.exec(input)\n  if (clock) return Number(clock[1]) * 60 + Number(clock[2])\n\n  // A bare number is minutes. `90` is an hour and a half, not ninety hours:\n  // the field is most often used for something that takes minutes, and a\n  // reader typing hours writes the `h`.\n  if (/^\\d+(?:[.,]\\d+)?$/.test(input)) return Math.round(Number(input.replace(',', '.')))\n\n  /* The general form: any number of `<number><unit>` pairs. A decimal is\n   * allowed on the hours (`1.5h`) because people write it, and a comma counts\n   * as a decimal point because half the world uses one. */\n  const pattern = /(\\d+(?:[.,]\\d+)?)\\s*([hm])/g\n  let total = 0\n  let matched = false\n  let consumed = 0\n\n  for (const match of input.matchAll(pattern)) {\n    matched = true\n    consumed += match[0].length\n    const amount = Number(match[1]!.replace(',', '.'))\n    total += match[2] === 'h' ? amount * 60 : amount\n  }\n\n  // Everything that is not whitespace has to have been part of a pair -\n  // otherwise `1h banana` would quietly parse as an hour.\n  if (!matched || consumed !== input.replace(/\\s+/g, '').length) return undefined\n\n  return Math.round(total)\n}\n\n/** The one spelling a duration is written back as: `1h 30m`, `45m`, `2h`.\n *\n * Zero is `0m` rather than blank, because a duration of zero is an answer -\n * blank is what `null` renders as. */\nexport function formatDuration(minutes: number): string {\n  const whole = Math.max(0, Math.round(minutes))\n  const hours = Math.floor(whole / 60)\n  const rest = whole % 60\n  if (hours === 0) return `${rest}m`\n  if (rest === 0) return `${hours}h`\n  return `${hours}h ${rest}m`\n}\n\nexport interface DurationFieldProps {\n  /** Minutes, or `null` for empty. */\n  value: number | null\n  onValueChange: (value: number | null) => void\n  /** What a reader sees before they type. A duration in the canonical\n   * spelling is the best hint there is, so this is the product's to give. */\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 DurationField({\n  value,\n  onValueChange,\n  className,\n  ref,\n  ...props\n}: DurationFieldProps) {\n  /* The field holds text while it is being typed and a number the rest of the\n   * time. Without the local copy, typing `1h 3` would reformat under the\n   * cursor after every keystroke - the classic controlled-input-with-parsing\n   * bug, where the field fights the person using it. */\n  const [text, setText] = useState(() => (value === null ? '' : formatDuration(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 - React's own pattern for a state that has to follow a\n   * prop, and the one the `set-state-in-effect` rule points at.\n   *\n   * The comparison has to be against what was last *seen*, not against\n   * `value`. After a commit the two differ in exactly the case that must be\n   * left alone: `commit` writes `1h 30m` and tells the parent, the parent\n   * still holds `null` for a tick, and comparing with `value` would clear the\n   * box under the reader. Asking \"has the outside changed?\" answers it. */\n  const [seen, setSeen] = useState<number | null>(value)\n\n  if (value !== seen) {\n    setSeen(value)\n    // A value that changed while someone is typing is remembered, not shown:\n    // reformatting under the cursor is the bug the local copy exists for.\n    if (!editing) setText(value === null ? '' : formatDuration(value))\n  }\n\n  const commit = () => {\n    setEditing(false)\n    const parsed = parseDuration(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(value === null ? '' : formatDuration(value))\n      return\n    }\n    setText(parsed === null ? '' : formatDuration(parsed))\n    if (parsed !== value) onValueChange(parsed)\n  }\n\n  return (\n    <input\n      {...props}\n      ref={ref}\n      type=\"text\"\n      inputMode=\"text\"\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"
        }
      ]
    },
    {
      "name": "field",
      "type": "registry:ui",
      "title": "Field",
      "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.",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/field.tsx",
          "target": "@ui/field.tsx",
          "type": "registry:ui",
          "content": "import type { ReactElement, ReactNode } from 'react'\nimport { Field as Base } from '@base-ui/react/field'\nimport { cn } from 'dowel-ui'\n\n/*\n * Field - the label, the control, and what the form has to say about it.\n *\n * Every form is the same four parts repeated: a name for the control, the\n * control, sometimes a hint, and sometimes an error. Written by hand each\n * time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no\n * screen reader mentions, the error appears in red and is announced by\n * nothing at all. This is that arrangement, once.\n *\n * The wiring is the point, and Base UI does it: the label points at the\n * control, the description and the error are named by `aria-describedby`, and\n * the control is marked invalid while an error is showing. None of that is\n * visible when it works, which is exactly why a product stops doing it.\n *\n * `error` is a string this is given, not a rule this enforces. dowel has no\n * opinion about where the string came from - Base UI's own `validate`, a\n * schema, react-hook-form, or a server that just said no - because a design\n * system that picked a form library would be choosing for products that\n * already chose. The one thing it insists on is that an error, once it\n * exists, is announced and points at the field it belongs to.\n *\n * The label is always rendered. A field whose name is only a placeholder\n * loses that name the moment someone types, and a placeholder is not a label\n * to anything that reads the page aloud; `labelHidden` takes it off the\n * screen and leaves it in the accessibility tree.\n */\n\nexport interface FieldProps {\n  /** What the control is called. Always rendered; `labelHidden` only takes it\n   * off the screen. */\n  label: ReactNode\n  /** The control itself - Input, Textarea, Select, Checkbox, anything.\n   *\n   * A single element rather than arbitrary nodes, because it is handed to\n   * Base UI to carry the field's id and its `aria-*` wiring. Two children, or\n   * a bare string, would leave the label naming nothing. */\n  children: ReactElement\n  /** A hint under the control. Hidden while an error is showing: two lines of\n   * small print under one field is one line too many, and the error is the\n   * one that matters. */\n  help?: ReactNode\n  /** What is wrong, if anything. Its presence is what marks the control\n   * invalid - there is no separate `invalid` prop to keep in step. */\n  error?: ReactNode\n  /** Keep the label for screen readers but take it off the screen. For a\n   * field whose meaning is obvious in context - a search box in a toolbar. */\n  labelHidden?: boolean\n  className?: string\n  /** Marks the field required, which is a statement about the form rather\n   * than about validation: the label gets the mark a reader looks for. */\n  required?: boolean\n  /** The control's `name`, forwarded so a `Form` can attach a server error to\n   * this field by name. */\n  name?: string\n  disabled?: boolean\n}\n\nexport function Field({\n  label,\n  children,\n  help,\n  error,\n  labelHidden = false,\n  className,\n  required = false,\n  name,\n  disabled = false,\n}: FieldProps) {\n  const invalid = error !== undefined && error !== null && error !== false\n\n  return (\n    <Base.Root\n      name={name}\n      disabled={disabled}\n      // Base UI marks the control invalid from its own validity state; this\n      // says so for an error that arrived from anywhere else.\n      invalid={invalid || undefined}\n      className={cn('flex flex-col gap-1.5', className)}\n    >\n      <Base.Label\n        className={cn(\n          'text-2xs font-medium uppercase tracking-caption text-faint',\n          // Off the screen, still in the accessibility tree. Not\n          // `display: none`, which would take it out of both.\n          labelHidden && 'sr-only',\n        )}\n      >\n        {label}\n        {required && (\n          <span aria-hidden className=\"ml-0.5 text-bad\">\n            *\n          </span>\n        )}\n      </Base.Label>\n\n      {/* The control is handed to Base UI rather than merely nested inside\n        * it. This is the whole component: a plain child renders a label whose\n        * `for` points at an id nothing carries, so the label looks wired and\n        * names nothing - which is exactly the bug this exists to prevent, and\n        * it is invisible in a screenshot. `render` gives the child the id,\n        * `aria-labelledby` and `aria-describedby` instead. */}\n      <Base.Control render={children} />\n\n      {/* The error wins the one line under the control. `match` is not used:\n        * the string is already the decision - this renders whatever it was\n        * handed rather than asking the browser what is wrong. */}\n      {invalid ? (\n        <Base.Error className=\"text-xs text-bad\" match>\n          {error}\n        </Base.Error>\n      ) : (\n        help !== undefined && <Base.Description className=\"text-xs text-dim\">{help}</Base.Description>\n      )}\n    </Base.Root>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "file-drop",
      "type": "registry:ui",
      "title": "File-drop",
      "description": "A place to put files: drag them onto it, or press it and pick them. It takes files and hands them over - it does not upload them. Where they go, with which credentials, retried how - that is the product's transport, and a primitive that owned it would be wrong for every product whose upload does not look like the one it guessed.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/file-drop.tsx",
          "target": "@ui/file-drop.tsx",
          "type": "registry:ui",
          "content": "import { useCallback, useId, useRef, useState, type DragEvent, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * FileDrop.\n *\n * A place to put files: drag them onto it, or press it and pick them. It takes\n * files and hands them over - it does not upload them. Where they go, with\n * which credentials, retried how - that is the product's transport, and a\n * primitive that owned it would be wrong for every product whose upload does\n * not look like the one it guessed.\n *\n * The boundary is the same one `Field` draws around validation: the component\n * knows the shape of the interaction, the product knows what the interaction\n * means.\n *\n * The parts that are easy to get wrong, and are therefore here:\n *\n * - **A real `<input type=\"file\">` underneath.** Not a div with a click\n *   handler. The native input is what makes the keyboard work, what the\n *   operating system's file picker attaches to, and what a screen reader\n *   announces as a file field. It is visually hidden, not `display: none` -\n *   hidden that way it is unfocusable, and the label stops reaching it.\n * - **The drag counter.** `dragleave` fires when the pointer crosses onto a\n *   *child* of the drop zone, so a zone that toggles on leave flickers as the\n *   pointer moves over its own text. Counting enters and leaves is the fix,\n *   and it is the single commonest defect in hand-written drop zones.\n * - **`dragover` must be prevented.** Without it the browser navigates to the\n *   file instead of dropping it - the drop appears to do nothing, and the page\n *   is replaced by a PDF.\n * - **Rejected files are reported, not swallowed.** A file dropped and\n *   silently ignored looks like a broken page. What is wrong with it is the\n *   product's word, so `onReject` hands back the file and the reason.\n */\n\n/** Why a file was not accepted. The product turns this into a sentence. */\nexport type FileRejection = {\n  file: File\n  reason: 'type' | 'size' | 'count'\n}\n\nexport interface FileDropProps {\n  /** Called with the files that passed the filters. */\n  onFiles: (files: File[]) => void\n  /** Called with the ones that did not, so the product can say why. */\n  onReject?: (rejections: FileRejection[]) => void\n  /** What to take, in the form `<input accept>` uses: `image/*`, `.pdf,.docx`. */\n  accept?: string\n  /** Largest file, in bytes. */\n  maxSize?: number\n  /** How many at once. Without it, any number. */\n  maxFiles?: number\n  /** Whether several may be chosen in the picker. */\n  multiple?: boolean\n  disabled?: boolean\n  /** What the zone says. The product's words, in the product's language. */\n  children?: ReactNode\n  className?: string\n  id?: string\n  /** Names the field for a screen reader, when there is no visible label. */\n  'aria-label'?: string\n  'aria-labelledby'?: string\n  'aria-describedby'?: string\n}\n\n/** Whether a file matches an `accept` list. Handles the three forms the\n * attribute takes: an extension, a full type, and a wildcard type. */\nfunction matchesAccept(file: File, accept: string | undefined): boolean {\n  if (!accept) return true\n  return accept\n    .split(',')\n    .map((entry) => entry.trim().toLowerCase())\n    .filter(Boolean)\n    .some((entry) => {\n      if (entry.startsWith('.')) return file.name.toLowerCase().endsWith(entry)\n      if (entry.endsWith('/*')) return file.type.toLowerCase().startsWith(entry.slice(0, -1))\n      return file.type.toLowerCase() === entry\n    })\n}\n\nexport function FileDrop({\n  onFiles,\n  onReject,\n  accept,\n  maxSize,\n  maxFiles,\n  multiple,\n  disabled,\n  children,\n  className,\n  id,\n  ...aria\n}: FileDropProps) {\n  const inputRef = useRef<HTMLInputElement>(null)\n  const generatedId = useId()\n  const inputId = id ?? generatedId\n\n  /* Nested elements each fire their own enter and leave as the pointer crosses\n   * them, so a boolean would flicker while the pointer moves across the zone's\n   * own label. The depth counter is what makes \"still inside\" answerable. */\n  const [depth, setDepth] = useState(0)\n  const over = depth > 0\n\n  const take = useCallback(\n    (list: FileList | null) => {\n      if (!list) return\n      const incoming = Array.from(list)\n      const accepted: File[] = []\n      const rejected: FileRejection[] = []\n\n      for (const file of incoming) {\n        if (!matchesAccept(file, accept)) rejected.push({ file, reason: 'type' })\n        else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: 'size' })\n        else if (maxFiles !== undefined && accepted.length >= maxFiles)\n          rejected.push({ file, reason: 'count' })\n        else accepted.push(file)\n      }\n\n      if (accepted.length) onFiles(accepted)\n      if (rejected.length) onReject?.(rejected)\n    },\n    [accept, maxFiles, maxSize, onFiles, onReject],\n  )\n\n  const onDrop = (event: DragEvent<HTMLDivElement>) => {\n    event.preventDefault()\n    setDepth(0)\n    if (disabled) return\n    take(event.dataTransfer.files)\n  }\n\n  return (\n    <div\n      onDragEnter={(event) => {\n        event.preventDefault()\n        setDepth((d) => d + 1)\n      }}\n      onDragLeave={(event) => {\n        event.preventDefault()\n        setDepth((d) => Math.max(0, d - 1))\n      }}\n      // Without preventing this, the browser opens the dropped file instead,\n      // and the page the reader was filling in is simply gone.\n      onDragOver={(event) => event.preventDefault()}\n      onDrop={onDrop}\n      // Presentational: the input inside is the control, and wrapping it in a\n      // second interactive element would give a screen reader two.\n      className={cn(\n        'rounded-lg border border-dashed border-line bg-raise p-6 text-center transition-colors',\n        over && !disabled && 'border-accent bg-accent-soft',\n        disabled && 'pointer-events-none opacity-50',\n        className,\n      )}\n    >\n      <label htmlFor={inputId} className={cn('block', !disabled && 'cursor-pointer')}>\n        {children}\n      </label>\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        {...aria}\n        onChange={(event) => {\n          take(event.target.files)\n          // Cleared so that choosing the same file twice in a row still fires\n          // a change - otherwise the second attempt silently does nothing.\n          event.target.value = ''\n        }}\n        /* Visually hidden, not `hidden`: it stays focusable, keeps its place\n         * in the tab order, and the label above still points at a real\n         * control. `display: none` would take all three away. */\n        className=\"sr-only\"\n      />\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "input",
      "type": "registry:ui",
      "title": "Input",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/input.tsx",
          "target": "@ui/input.tsx",
          "type": "registry:ui",
          "content": "import type { InputHTMLAttributes, Ref } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Input.\n *\n * A single-line field. It is a plain `<input>` with the line's clothes on, so\n * everything a browser gives an input for free - autofill, spellcheck, the\n * right keyboard on a phone, `type=\"email\"` validation - still works.\n *\n * The focus ring is the accent, drawn outside the border rather than replacing\n * it: a field that only changes colour on focus is invisible to anyone who\n * cannot distinguish those two colours.\n */\nexport const fieldClasses = cn(\n  'w-full rounded-md border border-line bg-transparent px-2.5 py-1.5',\n  'text-sm text-text placeholder:text-faint',\n  'transition-colors hover:border-line-2',\n  'focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent',\n  // A field nobody can type in should look like one.\n  'disabled:cursor-not-allowed disabled:opacity-50',\n  // `aria-invalid` rather than a prop: the attribute is what a screen reader\n  // reads, so making it the source of the colour keeps the two in step.\n  'aria-invalid:border-bad aria-invalid:focus-visible:outline-bad',\n)\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n  /** React 19 passes `ref` as a plain prop; it is declared so callers can\n   * reach the element to focus it or read its selection. */\n  ref?: Ref<HTMLInputElement>\n}\n\nexport function Input({ className, ref, ...props }: InputProps) {\n  return <input ref={ref} className={cn(fieldClasses, 'h-9', className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "kbd",
      "type": "registry:ui",
      "title": "Kbd",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/kbd.tsx",
          "target": "@ui/kbd.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Kbd.\n *\n * A key, as printed in a menu or a hint: `Ctrl` `K`. It is a `<kbd>` element\n * because that is what the element is for - a screen reader announces it as\n * keyboard input rather than reading a stray capital letter.\n *\n * The platform substitution is the useful part. A shortcut written `Ctrl+K` is\n * wrong on a Mac, where the same shortcut is `⌘K`, and every product either\n * hard-codes one of them or writes the branch again.\n */\n\n/** Whether this machine writes shortcuts the Apple way. */\nfunction isApplePlatform(): boolean {\n  if (typeof navigator === 'undefined') return false\n  return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)\n}\n\n/** What a key is called here. `Mod` is the one that differs: command on Apple\n * platforms, control everywhere else. */\nexport function keyLabel(key: string, apple: boolean = isApplePlatform()): string {\n  const shared: Record<string, string> = {\n    Enter: '↵',\n    Escape: 'Esc',\n    ArrowUp: '↑',\n    ArrowDown: '↓',\n    ArrowLeft: '←',\n    ArrowRight: '→',\n    Backspace: '⌫',\n    Tab: '⇥',\n    Space: '␣',\n  }\n  const perPlatform: Record<string, [apple: string, other: string]> = {\n    Mod: ['⌘', 'Ctrl'],\n    Alt: ['⌥', 'Alt'],\n    Shift: ['⇧', 'Shift'],\n  }\n\n  const platform = perPlatform[key]\n  if (platform) return apple ? platform[0] : platform[1]\n  return shared[key] ?? key\n}\n\nexport interface KbdProps extends HTMLAttributes<HTMLElement> {\n  /** Keys of a shortcut, in order: `['Mod', 'K']`. Given this, the component\n   * writes the separators and the platform's own names. */\n  keys?: string[]\n}\n\nexport function Kbd({ keys, className, children, ...props }: KbdProps) {\n  const cap = cn(\n    'inline-flex min-w-5 items-center justify-center rounded-sm border border-line bg-soft',\n    'px-1 py-0.5 font-mono text-2xs leading-none text-dim',\n  )\n\n  if (!keys) {\n    return (\n      <kbd className={cn(cap, className)} {...props}>\n        {children}\n      </kbd>\n    )\n  }\n\n  return (\n    <span className={cn('inline-flex items-center gap-0.5', className)} {...props}>\n      {keys.map((key) => (\n        <kbd key={key} className={cap}>\n          {keyLabel(key)}\n        </kbd>\n      ))}\n    </span>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "menu",
      "type": "registry:ui",
      "title": "Menu",
      "description": "A list of actions that opens from a button - the row menu, the overflow menu, the one behind the three dots. The products all wrote this by hand, and all of them wrote the same three hundred lines: a click-outside listener, an Escape handler, and a `stopPropagation` on every item so that choosing an action does not also open the row underneath.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/menu.tsx",
          "target": "@ui/menu.tsx",
          "type": "registry:ui",
          "content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n  [\n    'min-w-40 rounded-md border border-line bg-raise p-1 text-text shadow-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        sm: 'min-w-32',\n        md: 'min-w-40',\n        lg: 'min-w-56',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n  [\n    'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',\n    'outline-none transition-colors',\n    // Base UI marks the item under the pointer or the keyboard the same way,\n    // so one rule covers both and they cannot disagree.\n    'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n    'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n    '[&_svg]:size-3.5 [&_svg]:shrink-0',\n  ],\n  {\n    variants: {\n      tone: {\n        default: 'text-dim data-[highlighted]:text-text',\n        danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n      },\n    },\n    defaultVariants: { tone: 'default' },\n  },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof menuPopupVariants> {\n  /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n  side?: Base.Positioner.Props['side']\n  /** Alignment along that side. */\n  align?: Base.Positioner.Props['align']\n  /** Distance from the trigger, in pixels. */\n  sideOffset?: Base.Positioner.Props['sideOffset']\n  /** Where to portal to. Defaults to the document body, which keeps the menu\n   * from being clipped by a row with `overflow: hidden` - which is where most\n   * hand-written ones go to die. */\n  container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n  size,\n  side,\n  align,\n  sideOffset = 4,\n  container,\n  className,\n  children,\n  ...props\n}: MenuPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Positioner\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        className=\"[z-index:var(--z-menu)]\"\n      >\n        <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n          {children}\n        </Base.Popup>\n      </Base.Positioner>\n    </Base.Portal>\n  )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n  return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n  return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuItemProps) {\n  return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n  return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n  return (\n    <Base.GroupLabel\n      className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "number-field",
      "type": "registry:ui",
      "title": "Number-field",
      "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.",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/number-field.tsx",
          "target": "@ui/number-field.tsx",
          "type": "registry:ui",
          "content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n  value?: number | null\n  defaultValue?: number\n  onValueChange?: (value: number | null) => void\n  min?: number\n  max?: number\n  /** What the arrows change it by. */\n  step?: number\n  /** What PageUp and PageDown change it by, when a single step is too slow. */\n  largeStep?: number\n  /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n   * percentage is a prop rather than a wrapper. */\n  format?: Intl.NumberFormatOptions\n  /** Which conventions `format` follows. Left alone it is the reader's own,\n   * which is nearly always right; a product states one only when the figure\n   * belongs to a place rather than to a person - a price in a fixed market. */\n  locale?: Intl.LocalesArgument\n  /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n   * part of the value. */\n  unit?: ReactNode\n  /** Hide the stepper. For a field with a wide range, where the buttons are\n   * an invitation to click sixty times. */\n  hideStepper?: boolean\n  disabled?: boolean\n  readOnly?: boolean\n  required?: boolean\n  name?: string\n  placeholder?: string\n  'aria-label'?: string\n  className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n  'flex w-7 shrink-0 items-center justify-center text-dim',\n  'transition-colors hover:bg-soft hover:text-text',\n  'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n  unit,\n  hideStepper = false,\n  className,\n  placeholder,\n  'aria-label': ariaLabel,\n  ...props\n}: NumberFieldProps) {\n  return (\n    <Base.Root {...props} className={cn('inline-flex items-center gap-2', className)}>\n      <Base.Group\n        className={cn(\n          fieldClasses,\n          'flex h-9 items-stretch overflow-hidden p-0',\n          // The group carries the field's clothes, so the focus ring belongs\n          // to the whole control rather than to the bare input inside it.\n          'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n        )}\n      >\n        {!hideStepper && (\n          <Base.Decrement className={cn(stepperButton, 'border-r border-line')} aria-hidden>\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n              <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n            </svg>\n          </Base.Decrement>\n        )}\n\n        <Base.Input\n          placeholder={placeholder}\n          aria-label={ariaLabel}\n          className={cn(\n            'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n            'outline-none',\n            // Figures line up in a column, which is the whole reason a number\n            // is in a field of its own.\n            'tabular-nums',\n            hideStepper ? 'text-left' : 'text-center',\n          )}\n        />\n\n        {!hideStepper && (\n          <Base.Increment className={cn(stepperButton, 'border-l border-line')} aria-hidden>\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n              <path d=\"M8 4v8M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n            </svg>\n          </Base.Increment>\n        )}\n      </Base.Group>\n\n      {unit !== undefined && <span className=\"shrink-0 text-xs text-dim\">{unit}</span>}\n    </Base.Root>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "panel",
      "type": "registry:ui",
      "title": "Panel",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/panel.tsx",
          "target": "@ui/panel.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Panel.\n *\n * The raised surface everything else sits on. It is the one place a screen\n * gets its structure from, so it stays deliberately plain: a ground, a\n * hairline, a corner.\n *\n * The two products it came from disagreed about that corner - one used 16px,\n * the other 12px, for the component with the same name and the same purpose.\n * It is `lg` here, and `SectionLabel` is included because a panel almost\n * always has one and every product wrote its own.\n */\nexport const panelVariants = cva('bg-raise', {\n  variants: {\n    variant: {\n      /* The default: a surface that sits on the page. */\n      raised: 'rounded-lg border border-line',\n      /* For something that has left the page - a menu, a popover. The shadow\n       * is what says how far away it is. */\n      floating: 'rounded-lg border border-line shadow-raise',\n      /* Inside another panel, where a second border would be a box in a box. */\n      inset: 'rounded-inner bg-soft',\n    },\n  },\n  defaultVariants: { variant: 'raised' },\n})\n\nexport interface PanelProps\n  extends HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof panelVariants> {}\n\nexport function Panel({ variant, className, ...props }: PanelProps) {\n  return <div className={cn(panelVariants({ variant }), className)} {...props} />\n}\n\n/**\n * The small uppercase caption above a block of content.\n *\n * Its tracking is the one the products argued about - 0.08em in six files and\n * 0.09em in three - and it is a token now, so the argument cannot recur.\n */\nexport function SectionLabel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\n        'flex items-center gap-2 text-2xs font-medium uppercase tracking-caption text-faint',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "password-field",
      "type": "registry:ui",
      "title": "Password-field",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/password-field.tsx",
          "target": "@ui/password-field.tsx",
          "type": "registry:ui",
          "content": "import { useId, useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * PasswordField - a password, and the button that shows it.\n *\n * The reveal is the whole component, and it is not a convenience. A masked\n * field is the only one in a form where a typo cannot be seen, so people\n * either paste (fine) or type slowly and get it wrong anyway; the toggle is\n * what turns an unverifiable field into a checkable one, and it is why long\n * passphrases became usable at all.\n *\n * What it costs is a moment where the password is on the screen, so the\n * component states its two rules rather than leaving them to each product:\n *\n *   - it always starts masked, and there is no prop to start it revealed;\n *   - revealing is the reader's own action, never a default and never\n *     something a form can turn on for them.\n *\n * The button is a real button with a real name, and the name changes with the\n * state - \"Show password\" / \"Hide password\". That is what a screen reader\n * announces, and it is the one place the component needs words, so they are\n * required props. A default here would ship English inside a primitive.\n *\n * `autoComplete` is not defaulted either. The right value is the product's\n * to know: `current-password` on a login, `new-password` on a sign-up, and\n * getting it wrong is how a password manager fills the wrong box.\n */\n\nexport interface PasswordFieldProps {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** What the reveal button is called while the password is hidden. */\n  showLabel: string\n  /** ...and while it is showing. */\n  hideLabel: string\n  /** `current-password` for a login, `new-password` for a sign-up. */\n  autoComplete?: 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 PasswordField({\n  value,\n  defaultValue,\n  onValueChange,\n  showLabel,\n  hideLabel,\n  className,\n  disabled,\n  ref,\n  ...props\n}: PasswordFieldProps) {\n  /* Always false to begin with. Deliberately local state with no prop to set\n   * it: a password that arrives on screen without the reader asking is the\n   * one failure this component must not have. */\n  const [revealed, setRevealed] = useState(false)\n  const inputId = useId()\n\n  return (\n    <div\n      className={cn(\n        fieldClasses,\n        'flex h-9 items-stretch overflow-hidden p-0',\n        'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n        className,\n      )}\n    >\n      <input\n        {...props}\n        ref={ref}\n        id={props.id ?? inputId}\n        type={revealed ? 'text' : 'password'}\n        value={value}\n        defaultValue={defaultValue}\n        disabled={disabled}\n        onChange={(event) => onValueChange?.(event.target.value)}\n        className={cn(\n          'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n          'outline-none disabled:cursor-not-allowed',\n        )}\n      />\n\n      <button\n        type=\"button\"\n        // Not a submit button, and not in the tab order ahead of the field it\n        // belongs to - it sits after the input, which is where Tab reaches it.\n        onClick={() => setRevealed((was) => !was)}\n        disabled={disabled}\n        aria-label={revealed ? hideLabel : showLabel}\n        aria-pressed={revealed}\n        aria-controls={props.id ?? inputId}\n        className={cn(\n          'flex w-9 shrink-0 items-center justify-center 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          'disabled:pointer-events-none disabled:opacity-50',\n        )}\n      >\n        {revealed ? (\n          /* An eye with a stroke through it: hiding is the action offered\n           * while the password is visible. */\n          <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n            <path\n              d=\"M4 4l12 12M8.5 8.7a2 2 0 002.8 2.8M6.3 6.4C4.4 7.5 3 9.2 2.5 10c1.2 2.2 4 5 7.5 5 1.3 0 2.5-.4 3.5-1M9 5.1c.3 0 .7-.1 1-.1 3.5 0 6.3 2.8 7.5 5-.3.5-.8 1.3-1.6 2.1\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n            />\n          </svg>\n        ) : (\n          <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n            <path\n              d=\"M2.5 10C3.7 7.8 6.5 5 10 5s6.3 2.8 7.5 5c-1.2 2.2-4 5-7.5 5s-6.3-2.8-7.5-5z\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n            />\n            <circle cx=\"10\" cy=\"10\" r=\"2.2\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          </svg>\n        )}\n      </button>\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "popover",
      "type": "registry:ui",
      "title": "Popover",
      "description": "A panel anchored to the thing that opened it, for content that belongs beside a control rather than in the middle of the screen: a filter, a small form, a menu of settings that is not a menu.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/popover.tsx",
          "target": "@ui/popover.tsx",
          "type": "registry:ui",
          "content": "import { Popover as Base } from '@base-ui/react/popover'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Popover.\n *\n * A panel anchored to the thing that opened it, for content that belongs\n * beside a control rather than in the middle of the screen: a filter, a small\n * form, a menu of settings that is not a menu.\n *\n * Where Dialog is centred and covers the page, this one is placed, and the\n * placing is the part that is easy to get wrong. It goes through a Positioner\n * between the Portal and the Popup - Base UI measures the trigger, measures\n * the popup, and flips or shifts it when the preferred side does not fit. So\n * `side` and `align` are a preference, not an instruction, and a popover near\n * the bottom of the window will come out above its trigger. That is the\n * behaviour worth having; a popover that stays where it was told is a popover\n * half off the screen.\n *\n * `sideOffset` is a distance from the anchor and belongs to the Positioner,\n * not the Popup, so it is taken here and passed inward. The Arrow is optional\n * and sits inside the Popup, where Base UI rotates it to whichever side the\n * popup actually landed on.\n *\n * It is not modal. The page underneath stays live, and focus is not trapped -\n * which is right for a panel beside a control and wrong for a decision.\n */\n\nexport const popoverPopupVariants = cva(\n  [\n    'rounded-lg border border-line bg-raise p-4 text-text shadow-float',\n    'focus-visible:outline-none',\n    // The enter and the leave. `duration-*` reads the token directly because\n    // Tailwind's own utility takes a literal number.\n    '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n    'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n    'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n    // Grow out of the edge it is anchored to rather than out of its own\n    // middle, so the motion points back at the trigger.\n    'origin-[var(--transform-origin)]',\n  ],\n  {\n    variants: {\n      size: {\n        sm: 'w-[min(16rem,calc(100vw-2rem))]',\n        md: 'w-[min(20rem,calc(100vw-2rem))]',\n        lg: 'w-[min(28rem,calc(100vw-2rem))]',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PopoverTrigger`. */\nexport const Popover = Base.Root\n\n/** What opens it, and what the popup is measured against. */\nexport const PopoverTrigger = Base.Trigger\n\n/** What closes it, for a button inside the panel. */\nexport const PopoverClose = Base.Close\n\nexport interface PopoverPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof popoverPopupVariants> {\n  /** Preferred side of the trigger. Base UI flips it when it does not fit,\n   * and defaults it to the bottom. */\n  side?: Base.Positioner.Props['side']\n  /** Alignment along that side. Base UI centres it by default. */\n  align?: Base.Positioner.Props['align']\n  /** Distance from the trigger, in pixels. */\n  sideOffset?: Base.Positioner.Props['sideOffset']\n  /** Whether to draw the arrow pointing back at the trigger. */\n  arrow?: boolean\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * the popup from being clipped by an ancestor. Pass an element to put it\n   * somewhere else - inside an overlay that is already open, or into a\n   * container being screenshotted. */\n  container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned, so it is not clipped by an ancestor\n * with `overflow: hidden` - which is where most anchored popups go to die.\n *\n * `side` and `align` are passed straight through rather than defaulted here:\n * Base UI's own defaults are already bottom and centre, and writing them out\n * again would be one more place for the two to disagree. */\nexport function PopoverPopup({\n  size,\n  side,\n  align,\n  sideOffset = 8,\n  arrow = true,\n  container,\n  className,\n  children,\n  ...props\n}: PopoverPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Positioner\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        className=\"[z-index:var(--z-floating)]\"\n      >\n        <Base.Popup className={cn(popoverPopupVariants({ size }), className)} {...props}>\n          {arrow ? <PopoverArrow /> : null}\n          {children}\n        </Base.Popup>\n      </Base.Positioner>\n    </Base.Portal>\n  )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the popup landed on, which is why the rotation is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PopoverArrow({ className, ...props }: Base.Arrow.Props) {\n  return (\n    <Base.Arrow\n      className={cn(\n        'h-2 w-2 rotate-45 border border-line bg-raise',\n        'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n        'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n        'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n        'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * popover with one is named for a screen reader without anyone arranging it. */\nexport function PopoverTitle({ className, ...props }: Base.Title.Props) {\n  return <Base.Title className={cn('text-sm font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function PopoverDescription({ className, ...props }: Base.Description.Props) {\n  return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
        }
      ]
    },
    {
      "name": "preview-card",
      "type": "registry:ui",
      "title": "Preview-card",
      "description": "The card that appears when a link is hovered: who the author is, what the issue says, what is behind the URL. Rich content - an avatar, a few lines, a figure or two - rather than the phrase a Tooltip holds.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/preview-card.tsx",
          "target": "@ui/preview-card.tsx",
          "type": "registry:ui",
          "content": "import { PreviewCard as Base } from '@base-ui/react/preview-card'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * PreviewCard.\n *\n * The card that appears when a link is hovered: who the author is, what the\n * issue says, what is behind the URL. Rich content - an avatar, a few lines,\n * a figure or two - rather than the phrase a Tooltip holds.\n *\n * Positioned the same way a Popover is, through a Positioner between the\n * Portal and the Popup, so it flips and shifts to stay on screen. What differs\n * is how it opens: hovering the trigger, after a delay, and it stays open\n * while the pointer travels from the link to the card. That last part is the\n * whole trick - a card that vanishes when the pointer leaves the link cannot\n * be read, let alone clicked into.\n *\n * Same warning as Tooltip, and for the same reason. Base UI treats this as a\n * visual enhancement for sighted mouse and keyboard users: it is not reachable\n * on a touch screen and not announced by a screen reader. So NOTHING IN THE\n * CARD MAY BE THE ONLY PLACE IT APPEARS. Everything in it has to also be on\n * the page the link goes to - the card is a shortcut for people who can see\n * it, never the delivery mechanism for the information itself.\n *\n * If the content has to be reachable by everyone, this is the wrong component;\n * a Popover opened from a real button is the right one.\n */\n\nexport const previewCardPopupVariants = cva(\n  [\n    'rounded-lg border border-line bg-raise p-4 text-sm text-text shadow-float',\n    'focus-visible:outline-none',\n    // The enter and the leave. `duration-*` reads the token directly because\n    // Tailwind's own utility takes a literal number.\n    '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n    'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n    'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n    // Grow out of the edge it is anchored to rather than out of its own\n    // middle, so the motion points back at the link.\n    'origin-[var(--transform-origin)]',\n  ],\n  {\n    variants: {\n      size: {\n        sm: 'w-[min(18rem,calc(100vw-2rem))]',\n        md: 'w-[min(22rem,calc(100vw-2rem))]',\n        lg: 'w-[min(28rem,calc(100vw-2rem))]',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PreviewCardTrigger`. */\nexport const PreviewCard = Base.Root\n\n/** The link the card previews. Usually rendered as the anchor itself, so it\n * stays a real link: it navigates, it opens in a new tab, and a screen reader\n * announces it as one - which is what the card cannot do. Takes `delay`. */\nexport const PreviewCardTrigger = Base.Trigger\n\nexport interface PreviewCardPopupProps\n  extends Base.Popup.Props,\n    VariantProps<typeof previewCardPopupVariants> {\n  /** Preferred side of the link. Base UI flips it when it does not fit, and\n   * defaults it to the bottom. */\n  side?: Base.Positioner.Props['side']\n  /** Alignment along that side. Base UI centres it by default. */\n  align?: Base.Positioner.Props['align']\n  /** Distance from the link, in pixels. */\n  sideOffset?: Base.Positioner.Props['sideOffset']\n  /** Whether to draw the arrow pointing back at the link. */\n  arrow?: boolean\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * the popup from being clipped by an ancestor. Pass an element to put it\n   * somewhere else - inside an overlay that is already open, or into a\n   * container being screenshotted. */\n  container?: Base.Portal.Props['container']\n}\n\n/** The card. Portalled and positioned, so it is not clipped by the paragraph\n * the link sits in. */\nexport function PreviewCardPopup({\n  size,\n  side,\n  align,\n  sideOffset = 8,\n  arrow = true,\n  container,\n  className,\n  children,\n  ...props\n}: PreviewCardPopupProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Positioner\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        className=\"[z-index:var(--z-floating)]\"\n      >\n        <Base.Popup className={cn(previewCardPopupVariants({ size }), className)} {...props}>\n          {arrow ? <PreviewCardArrow /> : null}\n          {children}\n        </Base.Popup>\n      </Base.Positioner>\n    </Base.Portal>\n  )\n}\n\n/** The notch pointing back at the link. Base UI rotates it to whatever side\n * the card landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PreviewCardArrow({ className, ...props }: Base.Arrow.Props) {\n  return (\n    <Base.Arrow\n      className={cn(\n        'h-2 w-2 rotate-45 border border-line bg-raise',\n        'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n        'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n        'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n        'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "radio-group",
      "type": "registry:ui",
      "title": "Radio-group",
      "description": "The rule for reaching for this rather than a Select is whether the options are worth the space: a handful of short choices read faster laid out than hidden behind a trigger, and each one becomes a target rather than a step.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/radio-group.tsx",
          "target": "@ui/radio-group.tsx",
          "type": "registry:ui",
          "content": "import type { ReactNode } from 'react'\nimport { Radio as Base } from '@base-ui/react/radio'\nimport { RadioGroup as BaseGroup } from '@base-ui/react/radio-group'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * RadioGroup - one of a few, all of them visible.\n *\n * The rule for reaching for this rather than a Select is whether the options\n * are worth the space: a handful of short choices read faster laid out than\n * hidden behind a trigger, and each one becomes a target rather than a step.\n * Past about five, or when the labels are long, a Select is the honest\n * choice - this is not a Select with more pixels.\n *\n * The group is the control, not the button. That is what the arrow keys\n * follow, what a screen reader announces as one thing with a position in it,\n * and why `Radio` on its own is not exported: a radio outside a group is a\n * checkbox that cannot be unchecked.\n *\n * The dot is drawn rather than native, for the same reason as the tick in\n * Checkbox: a real `<input type=radio>` cannot be styled without\n * `appearance: none`, and after that the dot has to be drawn anyway.\n */\n\nexport interface RadioGroupProps {\n  children: ReactNode\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  disabled?: boolean\n  readOnly?: boolean\n  required?: boolean\n  name?: string\n  /** Lay the options out in a row. For two or three short ones; a column is\n   * the default because it stays readable as labels grow. */\n  orientation?: VariantProps<typeof radioGroupVariants>['orientation']\n  'aria-label'?: string\n  className?: string\n}\n\n/* The layout lives in `cva` rather than in a destructured default, which is\n * how every other primitive here states one. It also keeps the word out of\n * the component's signature: a string default in a props list is how a\n * primitive ends up shipping English, and the gate that watches for that\n * cannot tell an enum value from a label. */\nexport const radioGroupVariants = cva('flex', {\n  variants: {\n    orientation: {\n      vertical: 'flex-col gap-2',\n      horizontal: 'flex-row flex-wrap gap-4',\n    },\n  },\n  defaultVariants: { orientation: 'vertical' },\n})\n\nexport function RadioGroup({ children, orientation, className, ...props }: RadioGroupProps) {\n  return (\n    <BaseGroup className={cn(radioGroupVariants({ orientation }), className)} {...props}>\n      {children}\n    </BaseGroup>\n  )\n}\n\nexport interface RadioProps {\n  value: string\n  /** The words next to the dot. */\n  children?: ReactNode\n  disabled?: boolean\n  className?: string\n}\n\nexport function Radio({ value, children, disabled, className }: RadioProps) {\n  const dot = (\n    <Base.Root\n      value={value}\n      disabled={disabled}\n      className={cn(\n        'flex size-4 shrink-0 items-center justify-center rounded-full border border-line-2 bg-transparent',\n        'transition-colors',\n        'hover:border-accent',\n        'data-[checked]:border-accent',\n        'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n        'disabled:cursor-not-allowed disabled:opacity-50',\n        children === undefined && className,\n      )}\n    >\n      <Base.Indicator className=\"size-2 rounded-full bg-accent\" render={<span />} />\n    </Base.Root>\n  )\n\n  if (children === undefined) return dot\n\n  return (\n    <label\n      className={cn(\n        'flex cursor-pointer items-center gap-2 text-sm text-text',\n        'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n        className,\n      )}\n    >\n      {dot}\n      {children}\n    </label>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "rating-scale",
      "type": "registry:ui",
      "title": "Rating-scale",
      "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\".",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/rating-scale.tsx",
          "target": "@ui/rating-scale.tsx",
          "type": "registry:ui",
          "content": "import { useId, type KeyboardEvent } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * RatingScale - a judgement on a short scale, and the absence of one.\n *\n * Generalised from kilna, where it is how a work is scored on each of its\n * axes. The shape is a row of marks rather than stars: stars carry a meaning\n * of their own - a review, a public verdict - and this is as often \"how hard\n * was this\" or \"how finished is it\" as it is \"how good\".\n *\n * The part worth keeping from the donor, and the reason this is not a Slider\n * with a small range: **not judged yet is a state, not a zero.** \"I have not\n * scored this\" and \"I scored it nothing\" are different facts, and a control\n * that collapses them makes the difference unrecoverable the moment it is\n * saved. So `value` is `number | undefined`, clicking the current mark clears\n * it, and Backspace does the same from the keyboard.\n *\n * One tab stop, arrows within it - the arrangement a radio group has. The\n * marks are not buttons: the container is the control, and a `<button>` inside\n * an element with `role=\"slider\"` is a nested interactive control - axe calls\n * it out, and it is right, because assistive technology is not promised to\n * announce or reach the inner one. The donor had them as `aria-hidden`\n * buttons, which hides them from a reader without making them stop being\n * controls.\n *\n * So a mark is a plain element that happens to accept a click. Everything\n * that makes the control usable - the tab stop, the keyboard, the announced\n * value - belongs to the container, and the pointer is served by the marks.\n */\n\nexport interface RatingScaleProps {\n  /** How many marks. */\n  scale: number\n  /** The score, or `undefined` for not judged yet. */\n  value: number | undefined\n  onValueChange: (value: number | undefined) => void\n  /** What is being judged. Required: a bare row of marks names nothing, and\n   * this is the only thing a screen reader has to go on. */\n  label: string\n  /** What a screen reader hears in place of a number when nothing is chosen.\n   * Required rather than defaulted, because a default here would be English\n   * shipped inside a primitive. */\n  emptyLabel: string\n  disabled?: boolean\n  className?: string\n}\n\nexport function RatingScale({\n  scale,\n  value,\n  onValueChange,\n  label,\n  emptyLabel,\n  disabled = false,\n  className,\n}: RatingScaleProps) {\n  const id = useId()\n  const marks = Math.max(1, Math.round(scale))\n\n  const clamp = (next: number) => Math.min(Math.max(next, 0), marks)\n\n  const step = (delta: number) => {\n    if (disabled) return\n    // From nothing, a step forward lands on the first mark and a step back on\n    // the last - so either arrow starts scoring rather than doing nothing.\n    const next = value === undefined ? (delta > 0 ? 1 : marks) : value + delta\n    onValueChange(clamp(next))\n  }\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    if (disabled) return\n    switch (event.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        event.preventDefault()\n        step(1)\n        break\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        event.preventDefault()\n        step(-1)\n        break\n      case 'Home':\n        event.preventDefault()\n        onValueChange(0)\n        break\n      case 'End':\n        event.preventDefault()\n        onValueChange(marks)\n        break\n      case 'Backspace':\n      case 'Delete':\n        // The keyboard's way back to not judged. Without it the state is\n        // reachable only by clicking the mark that is already chosen.\n        event.preventDefault()\n        onValueChange(undefined)\n        break\n      default:\n        break\n    }\n  }\n\n  return (\n    <div\n      id={id}\n      role=\"slider\"\n      tabIndex={disabled ? -1 : 0}\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={marks}\n      aria-valuenow={value}\n      aria-valuetext={value === undefined ? emptyLabel : String(value)}\n      aria-disabled={disabled || undefined}\n      onKeyDown={onKeyDown}\n      className={cn(\n        'flex gap-[3px] rounded-md',\n        'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n        disabled && 'cursor-not-allowed opacity-50',\n        className,\n      )}\n    >\n      {Array.from({ length: marks }, (_, index) => {\n        const mark = index + 1\n        const filled = value !== undefined && mark <= value\n        return (\n          <span\n            key={mark}\n            // Deliberately not a button. The container is the control; a\n            // nested one would be a second interactive element inside a\n            // `slider`, which assistive technology is not promised to handle.\n            aria-hidden\n            // Clicking the mark already chosen clears the score. That is the\n            // pointer's way back to not judged, and it is why a rating is not\n            // a five-option radio group.\n            onClick={() => {\n              if (disabled) return\n              onValueChange(value === mark ? undefined : mark)\n            }}\n            className={cn(\n              'h-[22px] flex-1 rounded-sm transition-colors',\n              disabled ? 'cursor-not-allowed' : 'cursor-pointer',\n              filled ? 'bg-accent' : 'bg-soft',\n              !disabled && (filled ? 'hover:bg-accent-2' : 'hover:bg-line-2'),\n            )}\n          />\n        )\n      })}\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "save-state",
      "type": "registry:ui",
      "title": "Save-state",
      "description": "The quiet line beside a field that saves itself: \"saving…\", then a tick that fades. It exists because a form without a Save button has to say what it did anyway - otherwise the reader is left guessing whether their edit survived, and the usual answer to that guess is to press Ctrl+S at a page that has no such thing.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/spinner.json"
      ],
      "files": [
        {
          "path": "ui/save-state.tsx",
          "target": "@ui/save-state.tsx",
          "type": "registry:ui",
          "content": "import { useEffect, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { spinnerVariants } from './spinner'\n\n/*\n * SaveState.\n *\n * The quiet line beside a field that saves itself: \"saving…\", then a tick that\n * fades. It exists because a form without a Save button has to say what it did\n * anyway - otherwise the reader is left guessing whether their edit survived,\n * and the usual answer to that guess is to press Ctrl+S at a page that has no\n * such thing.\n *\n * Taken from kilna, where it has been in production, with the two things a\n * primitive may not keep: its words (they came from the product's i18n, and a\n * component with a string of its own cannot be translated) and its icons.\n *\n * The rules it already got right, and which are the whole reason not to write\n * this fresh:\n *\n * - **The tick decays.** One that never leaves stops meaning \"just now\" and\n *   becomes furniture.\n * - **Only a real save earns one.** Mounting next to an idle mutation must not\n *   flash a tick for a save that happened before the reader arrived.\n * - **A failure is not a save.** It is announced elsewhere - a toast, an error\n *   on the field - and saying \"saved\" underneath that is worse than silence.\n * - **The width is held.** The line sits next to a field; if it grew and shrank\n *   with its own text, the layout would twitch on every keystroke's worth of\n *   saving.\n */\n\n/** What the indicator is showing. `idle` is invisible but still occupies its\n * width. */\nexport type SaveStatus = 'idle' | 'saving' | 'saved'\n\n/**\n * Turns \"is a mutation in flight\" into the three states this shows.\n *\n * Only the tick is state: `saving` is read straight off the mutation, and the\n * tick decays on its own.\n *\n * @param isPending whether the save is in flight right now\n * @param isError whether the save that just finished failed\n * @param linger how long the tick stays, in milliseconds\n */\nexport function useSaveStatus(isPending: boolean, isError = false, linger = 2000): SaveStatus {\n  const [justSaved, setJustSaved] = useState(false)\n  // The falling edge of a real save is the only thing worth a tick. Without\n  // this, mounting beside an idle mutation would show one immediately, for a\n  // save the reader was not there for.\n  const [wasPending, setWasPending] = useState(isPending)\n\n  // Adjusted during render rather than in an effect: this is state that\n  // follows a prop, and `react-hooks/set-state-in-effect` is right to say so.\n  if (wasPending !== isPending) {\n    setWasPending(isPending)\n    setJustSaved(!isPending && !isError)\n  }\n\n  useEffect(() => {\n    if (!justSaved) return\n    const timer = setTimeout(() => setJustSaved(false), linger)\n    return () => clearTimeout(timer)\n  }, [justSaved, linger])\n\n  if (isPending) return 'saving'\n  return justSaved ? 'saved' : 'idle'\n}\n\nexport interface SaveStateProps {\n  /** What to show. Usually from `useSaveStatus`. */\n  status: SaveStatus\n  /** What to say while saving. The product's word, in the product's language. */\n  savingLabel?: ReactNode\n  /** What to say once it is saved. */\n  savedLabel?: ReactNode\n  className?: string\n}\n\n/**\n * The indicator itself.\n *\n * `aria-live=\"polite\"` rather than `assertive`: this is a background fact, and\n * interrupting someone mid-sentence to tell them a field saved is precisely\n * the kind of announcement that makes people turn a screen reader's verbosity\n * down.\n */\nexport function SaveState({ status, savingLabel, savedLabel, className }: SaveStateProps) {\n  return (\n    <span\n      aria-live=\"polite\"\n      className={cn(\n        'inline-flex min-w-16 items-center gap-1 text-xs text-faint transition-opacity',\n        // Kept in the layout when idle, so the row does not reflow around it.\n        status === 'idle' && 'opacity-0',\n        className,\n      )}\n    >\n      {status === 'saving' && (\n        <>\n          {/* The ring, not `Spinner`.\n            *\n            * `Spinner` carries its own `role=\"status\"` and `aria-live`, which\n            * is right when it stands alone and wrong inside this: nesting one\n            * live region in another gives a screen reader two things to\n            * announce for one event. So the visual is reused through the\n            * variants and the announcing is left to the span around it. */}\n          <span aria-hidden className={cn(spinnerVariants({ size: 'sm', tone: 'current' }), 'border-r-transparent')} />\n          {savingLabel}\n        </>\n      )}\n      {status === 'saved' && (\n        <>\n          {/* The tick is the only place this component names a colour, and it\n              names the token for \"this went well\" rather than a green. */}\n          <svg\n            viewBox=\"0 0 16 16\"\n            aria-hidden=\"true\"\n            className=\"size-3 text-good\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <path d=\"M3 8.5 6.5 12 13 4.5\" />\n          </svg>\n          {savedLabel}\n        </>\n      )}\n    </span>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "search-field",
      "type": "registry:ui",
      "title": "Search-field",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/kbd.json",
        "https://lacodda.github.io/dowel/r/shortcut.json"
      ],
      "files": [
        {
          "path": "ui/search-field.tsx",
          "target": "@ui/search-field.tsx",
          "type": "registry:ui",
          "content": "import { useCallback, useRef, type InputHTMLAttributes, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Kbd } from './kbd'\nimport { useShortcut } from './shortcut'\n\n/*\n * SearchField.\n *\n * An Input that knows it is a search box, which is three small things the\n * products kept not doing:\n *\n *   - a magnifier, so the field is recognisable before it is read;\n *   - a way to clear it that is not \"select all and delete\" - and one that a\n *     keyboard can reach, which a decorative `<span>` cannot;\n *   - the shortcut that focuses it, shown in the field rather than learned.\n *\n * `type=\"search\"` is deliberate: it is what tells a browser to offer previous\n * queries, and what makes Escape clear the field on the platforms where that\n * is the convention. The browser's own clear button is hidden, because it is\n * drawn in the operating system's chrome and cannot be made to match - the\n * same reason the line does not use a native `<select>`.\n */\n\nexport interface SearchFieldProps\n  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {\n  /** The query. Controlled, because a search box that owns its own text\n   * cannot be cleared by the thing that owns the results. */\n  value: string\n  /** Told the new query on every keystroke. */\n  onValueChange: (value: string) => void\n  /**\n   * What the clear button is called, for a screen reader. No default: a word\n   * the component invents is a word the product cannot translate.\n   *\n   * Leave it out and no clear button is drawn - which is the right shape for a\n   * field that filters as you type and is cleared by other means.\n   */\n  clearLabel?: string\n  /**\n   * The shortcut that focuses the field, as `['Mod', 'K']`. Shown at the right\n   * of the field, and bound: pressing it focuses and selects, from anywhere\n   * that is not already a field.\n   */\n  shortcut?: string[]\n  ref?: Ref<HTMLInputElement>\n}\n\nexport function SearchField({\n  value,\n  onValueChange,\n  clearLabel,\n  shortcut,\n  className,\n  ref,\n  ...props\n}: SearchFieldProps) {\n  const own = useRef<HTMLInputElement>(null)\n\n  const setRefs = useCallback(\n    (element: HTMLInputElement | null) => {\n      own.current = element\n      if (typeof ref === 'function') ref(element)\n      else if (ref) ref.current = element\n    },\n    [ref],\n  )\n\n  // Focus and select, so the shortcut replaces a stale query rather than\n  // appending to it. Not while someone is typing elsewhere - that is\n  // `useShortcut`'s default, and it is the half of this people forget.\n  const focusAndSelect = useCallback(() => {\n    own.current?.focus()\n    own.current?.select()\n  }, [])\n  useShortcut(shortcut ?? [], focusAndSelect, { enabled: shortcut !== undefined })\n\n  const showClear = clearLabel !== undefined && value !== ''\n\n  return (\n    <div className={cn('relative', className)}>\n      <MagnifierIcon />\n\n      <input\n        ref={setRefs}\n        type=\"search\"\n        value={value}\n        onChange={(event) => onValueChange(event.target.value)}\n        className={cn(\n          fieldClasses,\n          'h-9 pl-8',\n          // Room on the right for whatever sits there, and none when nothing\n          // does - a field with a permanent gap looks broken.\n          showClear && 'pr-8',\n          !showClear && shortcut && 'pr-14',\n          // The browser's own clear affordance, in the operating system's\n          // chrome. Ours is below.\n          '[&::-webkit-search-cancel-button]:appearance-none',\n        )}\n        {...props}\n      />\n\n      {showClear && (\n        <button\n          type=\"button\"\n          aria-label={clearLabel}\n          onClick={() => {\n            onValueChange('')\n            own.current?.focus()\n          }}\n          className={cn(\n            'absolute right-1.5 top-1/2 grid size-6 -translate-y-1/2 place-items-center',\n            'rounded-sm text-faint transition-colors hover:bg-soft hover:text-text',\n            'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n          )}\n        >\n          <CrossIcon />\n        </button>\n      )}\n\n      {!showClear && shortcut && (\n        // Decorative: the shortcut works whether or not it is read out, and a\n        // screen reader announcing \"Control K\" inside a search box is noise.\n        <Kbd\n          keys={shortcut}\n          aria-hidden\n          className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2\"\n        />\n      )}\n    </div>\n  )\n}\n\nfunction MagnifierIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      width=\"14\"\n      height=\"14\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.6\"\n      aria-hidden\n      className=\"pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 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\nfunction CrossIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n      <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n    </svg>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "select",
      "type": "registry:ui",
      "title": "Select",
      "description": "The component the oldest rule in the line is about. A native `<select>` cannot be dressed: the browser draws its popup itself, in the operating system's chrome, and no CSS reaches inside. One native dropdown on a screen of the product's own controls reads as a foreign object, and on Windows it reads as a foreign object from 1998.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/select.tsx",
          "target": "@ui/select.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "shortcut",
      "type": "registry:ui",
      "title": "Shortcut",
      "description": "Two things every product writes and half of them get subtly wrong: deciding whether a keystroke is the shortcut, and deciding whether now is a moment to act on it.",
      "dependencies": [],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/shortcut.tsx",
          "target": "@ui/shortcut.tsx",
          "type": "registry:ui",
          "content": "import { useEffect } from 'react'\n\n/*\n * Shortcut.\n *\n * Two things every product writes and half of them get subtly wrong: deciding\n * whether a keystroke is the shortcut, and deciding whether now is a moment to\n * act on it.\n *\n * The second is the one that bites. A shortcut that fires while someone is\n * typing in a field is a bug nobody can describe afterwards - `Mod+K` inside a\n * text editor means \"delete to end of line\", and a palette that opens on top\n * of it looks like the application misheard. So a keystroke aimed at an input,\n * a textarea or anything `contenteditable` belongs to that thing, always.\n *\n * A shortcut is written the way it is read: `['Mod', 'K']`. `Mod` is command\n * on Apple platforms and control everywhere else, which is the same rule Kbd\n * draws it by, so what is bound and what is shown cannot disagree.\n */\n\n/** Whether the event landed somewhere that owns its own keys. */\nexport function isTypingTarget(target: EventTarget | null): boolean {\n  const element = target as HTMLElement | null\n  if (!element) return false\n  const tag = element.tagName\n  // Coerced: `isContentEditable` is computed from layout, and an engine that\n  // does not lay out - jsdom, and any server render - leaves it `undefined`\n  // rather than `false`. Truthiness is enough for the branch below, but a\n  // function that says it returns a boolean has to.\n  return tag === 'INPUT' || tag === 'TEXTAREA' || Boolean(element.isContentEditable)\n}\n\n/** Does this event match the shortcut? Exported because a product sometimes\n * has to ask the question inside a handler it already owns. */\nexport function matchesShortcut(event: KeyboardEvent, shortcut: string[]): boolean {\n  const wants = new Set(shortcut.map((key) => key.toLowerCase()))\n  const mod = wants.delete('mod')\n  const shift = wants.delete('shift')\n  const alt = wants.delete('alt')\n  const [key] = [...wants]\n  if (key === undefined) return false\n\n  // An exact match on every modifier, in both directions: `Mod+K` must not\n  // fire on `Mod+Shift+K`, which is usually a different command entirely.\n  if (mod !== (event.metaKey || event.ctrlKey)) return false\n  if (shift !== event.shiftKey) return false\n  if (alt !== event.altKey) return false\n  return event.key.toLowerCase() === key\n}\n\nexport interface UseShortcutOptions {\n  /** Bind it at all. For a shortcut that only exists on some screens. */\n  enabled?: boolean\n  /**\n   * Fire even while someone is typing in a field.\n   *\n   * Off by default, and the default is the point. Turn it on for a shortcut\n   * that belongs to the field itself - `Escape` closing the box it is typed\n   * in - and never for one that takes the person somewhere else.\n   */\n  whileTyping?: boolean\n}\n\n/**\n * Run something when a shortcut is pressed.\n *\n * The handler is read from a ref, so a caller that writes it inline does not\n * rebind the listener on every render - which is how these end up firing twice.\n */\nexport function useShortcut(\n  shortcut: string[],\n  onPress: (event: KeyboardEvent) => void,\n  { enabled = true, whileTyping = false }: UseShortcutOptions = {},\n): void {\n  useEffect(() => {\n    if (!enabled) return\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (!whileTyping && isTypingTarget(event.target)) return\n      if (!matchesShortcut(event, shortcut)) return\n      event.preventDefault()\n      onPress(event)\n    }\n\n    document.addEventListener('keydown', onKeyDown)\n    return () => document.removeEventListener('keydown', onKeyDown)\n    // `shortcut` is an array literal at most call sites, so it is joined\n    // rather than compared by identity: otherwise every render rebinds.\n  }, [shortcut.join('+'), onPress, enabled, whileTyping]) // eslint-disable-line react-hooks/exhaustive-deps\n}\n"
        }
      ]
    },
    {
      "name": "slider",
      "type": "registry:ui",
      "title": "Slider",
      "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\".",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/slider.tsx",
          "target": "@ui/slider.tsx",
          "type": "registry:ui",
          "content": "import { Slider as Base } from '@base-ui/react/slider'\nimport { cn } from 'dowel-ui'\n\n/*\n * Slider - a value picked by position, and a range picked by two.\n *\n * The case for it over a NumberField is that the number does not matter much:\n * a volume, an opacity, a weight in a search filter. Where the exact figure\n * does matter, a slider is a worse field with more pixels - it cannot be\n * typed into, it cannot be pasted into, and it has no state for \"empty\".\n *\n * Range is the same component with an array. That is Base UI's arrangement\n * and it is the right one: a range slider is not a second control but the\n * same track with two thumbs, and splitting them would double the styling\n * and let the two drift apart. `value={[10, 40]}` is a range; `value={30}` is\n * a single.\n *\n * The parts are separate for a reason worth knowing. The Control is the whole\n * hit area - much taller than the visible track, so a pointer does not have\n * to find four pixels - while the Track is what is drawn, and the Indicator\n * is the filled part behind the thumb. Making the drawn track the hit area is\n * the commonest way a slider ends up hard to grab.\n */\n\nexport interface SliderProps {\n  /** A number for one thumb, an array for a range. */\n  value?: number | readonly number[]\n  defaultValue?: number | readonly number[]\n  /* `readonly` because that is what Base UI hands over, and narrowing it here\n   * only moves the cast into every caller. */\n  onValueChange?: (value: number | readonly number[]) => void\n  /** Fires once when the drag ends, for the expensive thing a product does\n   * not want to run on every pixel of movement. */\n  onValueCommitted?: (value: number | readonly number[]) => void\n  min?: number\n  max?: number\n  step?: number\n  /** How close the thumbs of a range may come, in steps. */\n  minStepsBetweenValues?: number\n  /** How the value reads when it is shown: `Intl.NumberFormat` options. */\n  format?: Intl.NumberFormatOptions\n  orientation?: 'horizontal' | 'vertical'\n  /** Show the current value beside the track. Off by default: on a row of\n   * settings the numbers are noise, and where the figure matters a\n   * NumberField is the better control. */\n  showValue?: boolean\n  /** What each thumb is called, by index.\n   *\n   * The thumb is the control - a hidden `<input type=\"range\">` - and\n   * `aria-label` on the root names the *group* around it, which leaves every\n   * thumb unnamed. axe reports it for a single slider as loudly as for a\n   * range. Without this prop the group's own label is used for each thumb,\n   * which is right for one and merely adequate for two: a range wants\n   * \"Lowest price\" and \"Highest price\", not the same word twice. */\n  getThumbLabel?: (index: number) => string\n  disabled?: boolean\n  name?: string\n  'aria-label'?: string\n  className?: string\n}\n\nexport function Slider({ showValue = false, getThumbLabel, className, ...props }: SliderProps) {\n  const vertical = props.orientation === 'vertical'\n\n  /* Every thumb needs a name of its own, so the group's label is the fallback\n   * rather than nothing. A slider whose only name sits on the group is one\n   * where a reader lands on the control and is told a number. */\n  const groupLabel = props['aria-label']\n  const nameThumb = getThumbLabel ?? (groupLabel === undefined ? undefined : () => groupLabel)\n\n  /* One thumb per value. Base UI addresses them by index, so a range needs as\n   * many as the array is long - a single Thumb on a range renders one handle\n   * that moves the first value and leaves the second unreachable. Read from\n   * whichever of the two props is present, because the component is useful\n   * both controlled and not. */\n  const current = props.value ?? props.defaultValue ?? 0\n  const thumbCount = Array.isArray(current) ? current.length : 1\n\n  return (\n    <Base.Root\n      {...props}\n      className={cn(\n        'flex items-center gap-3',\n        vertical && 'h-40 flex-col',\n        !vertical && 'w-full',\n        className,\n      )}\n    >\n      <Base.Control\n        className={cn(\n          // The hit area, deliberately larger than what is drawn: a four-pixel\n          // target is a four-pixel target however pretty the track is.\n          'flex touch-none items-center',\n          vertical ? 'h-full w-5 justify-center' : 'h-5 w-full flex-1',\n        )}\n      >\n        <Base.Track\n          className={cn(\n            'relative rounded-full bg-soft',\n            vertical ? 'h-full w-1.5' : 'h-1.5 w-full',\n          )}\n        >\n          <Base.Indicator className={cn('rounded-full bg-accent', vertical ? 'w-full' : 'h-full')} />\n          {Array.from({ length: thumbCount }, (_, index) => (\n            <Base.Thumb\n              key={index}\n              index={index}\n              getAriaLabel={nameThumb}\n              className={cn(\n                'size-4 rounded-full bg-accent shadow-lift',\n                'transition-[box-shadow,transform] duration-quick ease-out',\n                'hover:scale-110',\n                'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n                'data-[dragging]:scale-110',\n              )}\n            />\n          ))}\n        </Base.Track>\n      </Base.Control>\n\n      {showValue && (\n        <Base.Value className=\"shrink-0 text-xs tabular-nums text-dim\" />\n      )}\n    </Base.Root>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "spinner",
      "type": "registry:ui",
      "title": "Spinner",
      "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.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/spinner.tsx",
          "target": "@ui/spinner.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Spinner.\n *\n * Something is happening and the answer has not arrived. It carries no text of\n * its own - what is loading is the product's word, not the system's - but it\n * does have to say *something* to a screen reader, or a page that is busy is\n * silently identical to a page that is empty.\n *\n * Under `prefers-reduced-motion` the theme stops the animation. That is not a\n * detail: for some readers a spinning thing is not decoration but a symptom.\n * A stopped spinner still says \"busy\" through `role`, which is the part that\n * carried the meaning all along.\n */\nexport const spinnerVariants = cva('inline-block animate-spin rounded-full border-2 border-current', {\n  variants: {\n    size: {\n      sm: 'size-3.5 border-[1.5px]',\n      md: 'size-4',\n      lg: 'size-6',\n    },\n    tone: {\n      /* Follows the text it sits in. */\n      current: 'text-current',\n      dim: 'text-dim',\n      accent: 'text-accent',\n    },\n  },\n  defaultVariants: { size: 'md', tone: 'current' },\n})\n\nexport interface SpinnerProps\n  extends HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof spinnerVariants> {\n  /** What is being waited for, for a screen reader. The product's word. */\n  label?: string\n}\n\nexport function Spinner({ size, tone, label, className, ...props }: SpinnerProps) {\n  return (\n    <span role=\"status\" aria-live=\"polite\" className={cn('inline-flex items-center gap-2', className)} {...props}>\n      <span\n        // The gap in the ring is what makes the rotation visible.\n        className={cn(spinnerVariants({ size, tone }), 'border-r-transparent')}\n        aria-hidden\n      />\n      {label && <span className=\"sr-only\">{label}</span>}\n    </span>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "switch",
      "type": "registry:ui",
      "title": "Switch",
      "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.",
      "dependencies": [
        "@base-ui/react",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/switch.tsx",
          "target": "@ui/switch.tsx",
          "type": "registry:ui",
          "content": "import type { ReactNode } from 'react'\nimport { Switch as Base } from '@base-ui/react/switch'\nimport { cn } from 'dowel-ui'\n\n/*\n * Switch - a setting that takes effect when you flip it.\n *\n * The difference from Checkbox is not how it looks, and getting it wrong is\n * the commonest mistake in the pair. A checkbox is an answer collected now\n * and submitted later, with the rest of the form; a switch is a setting that\n * applies the moment it moves. Put a switch in a form with a Save button and\n * the reader cannot tell whether anything happened - they flipped it, and\n * nothing said so.\n *\n * The rule, then: if there is a Save button, it is a Checkbox. If the change\n * is the action, it is a Switch.\n *\n * The thumb slides with `--duration-quick`, and the track carries the accent\n * when on - the only colour in the control, so a row of settings reads as a\n * column of on-and-off rather than a field of decoration. Under reduced\n * motion the theme drops the transition; the position still changes, which is\n * the part that carries the meaning.\n */\n\nexport interface SwitchProps {\n  /** The words next to the switch. A switch with no label is a light with no\n   * caption - give `aria-label` if the meaning is genuinely in the context. */\n  children?: ReactNode\n  checked?: boolean\n  defaultChecked?: boolean\n  onCheckedChange?: (checked: boolean) => void\n  disabled?: boolean\n  required?: boolean\n  readOnly?: boolean\n  name?: string\n  'aria-label'?: string\n  className?: string\n}\n\nexport function Switch({ children, className, ...props }: SwitchProps) {\n  const control = (\n    <Base.Root\n      className={cn(\n        'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-line-2 bg-soft',\n        'transition-colors duration-quick ease-out',\n        'hover:border-accent',\n        'data-[checked]:border-accent data-[checked]:bg-accent',\n        'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n        'disabled:cursor-not-allowed disabled:opacity-50',\n        children === undefined && className,\n      )}\n      {...props}\n    >\n      <Base.Thumb\n        className={cn(\n          'size-3.5 rounded-full bg-dim shadow-lift',\n          'transition-[transform,background-color] duration-quick ease-out',\n          'translate-x-0.5 data-[checked]:translate-x-[1.125rem]',\n          'data-[checked]:bg-on-accent',\n        )}\n      />\n    </Base.Root>\n  )\n\n  if (children === undefined) return control\n\n  return (\n    <label\n      className={cn(\n        'flex cursor-pointer items-center gap-2.5 text-sm text-text',\n        'has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',\n        className,\n      )}\n    >\n      {control}\n      {children}\n    </label>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "tag-input",
      "type": "registry:ui",
      "title": "Tag-input",
      "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
      "dependencies": [
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/chip.json",
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/tag-input.tsx",
          "target": "@ui/tag-input.tsx",
          "type": "registry:ui",
          "content": "import { useCallback, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Chip } from './chip'\n\n/*\n * TagInput.\n *\n * Free text turned into a list: type a word, press Enter, it becomes a chip.\n * This is the field for labels, keywords, recipients - anywhere the set of\n * values is not known in advance, which is exactly what separates it from\n * `Combobox multiple`. Choosing from a list is that component's job; this one\n * is for values that do not exist until someone types them.\n *\n * Base UI has no part for this, and the reason is worth stating: its Combobox\n * chips are for values chosen from a collection, and \"create the thing I just\n * typed\" is a decision about the product's data, not about the widget. So the\n * behaviour here is written, and written narrowly.\n *\n * What it does, and why each one is not optional:\n *\n * - **Enter commits.** The obvious one, and the reason it is a field at all.\n * - **Backspace on an empty input removes the last tag.** Without it the only\n *   way back is the mouse, and a list built by typing should be unbuildable\n *   by typing too.\n * - **Blur commits what was typed.** A word left in the box when the reader\n *   clicks Save is a word they believe they entered. Losing it silently is\n *   the single commonest complaint about fields of this shape.\n * - **Duplicates are refused, not stacked.** A tag list is a set.\n * - **Whitespace is trimmed and empties dropped**, so Enter on a blank input\n *   does nothing rather than adding a tag nobody can see.\n *\n * What it deliberately does not do: validate. What makes a tag acceptable -\n * length, charset, whether it must already exist - is the product's rule, and\n * a component that guessed would be wrong in both directions. Reject a value\n * by not putting it in `value`.\n */\n\nexport const tagInputVariants = cva(\n  cn(\n    fieldClasses,\n    'flex flex-wrap items-center gap-1 py-1',\n    'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n  ),\n  {\n    variants: {\n      size: {\n        sm: 'min-h-8 text-xs',\n        md: 'min-h-9',\n        lg: 'min-h-10 text-base',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\n/* The box inside the box.\n *\n * `min-h-*` on the container sets a floor; the input inside it has a height of\n * its own, and that is what a reader actually sees. A size that moved only the\n * container moved nothing - measured, `sm` and `md` both came out 38px tall,\n * because the input was `h-7` in all three and its padding decided the rest.\n *\n * `tailwind-merge` does not rescue this either: `h-7` and `min-h-8` are not\n * the same property, so both survive and the fixed height wins. The two have\n * to be declared together.\n */\nconst tagInputBoxVariants = cva(\n  'w-auto min-w-24 flex-1 bg-transparent px-1 text-text outline-none placeholder:text-faint',\n  {\n    variants: {\n      size: {\n        sm: 'h-6 text-xs',\n        md: 'h-7 text-sm',\n        lg: 'h-8 text-base',\n      },\n    },\n    defaultVariants: { size: 'md' },\n  },\n)\n\nexport interface TagInputProps extends VariantProps<typeof tagInputVariants> {\n  /** The tags. Controlled: the product owns the list. */\n  value: string[]\n  /** Called with the next list. */\n  onValueChange: (value: string[]) => void\n  /** Placeholder for the text box, shown when there is room for it. */\n  placeholder?: string\n  /** Names the field. Required - a box of chips with no label names nothing. */\n  'aria-label'?: string\n  /** Or points at a label element, when there is a visible one. */\n  'aria-labelledby'?: string\n  /** How a tag's remove button is named, for a screen reader. Takes the tag,\n   * so the product can say \"Remove documentation\" in its own language.\n   *\n   * Required, like `Chip`'s own: a remove button exists only when there is a\n   * word for it, and a word this component invented would ship in English to\n   * every reader who does not read English. */\n  removeLabel: (tag: string) => string\n  /** Cap on how many tags may be added. */\n  max?: number\n  disabled?: boolean\n  /** Drawn after the input, inside the box - a counter, a hint. */\n  children?: ReactNode\n  className?: string\n  id?: string\n}\n\nexport function TagInput({\n  value,\n  onValueChange,\n  placeholder,\n  removeLabel,\n  max,\n  disabled,\n  size,\n  children,\n  className,\n  id,\n  ...aria\n}: TagInputProps) {\n  const [draft, setDraft] = useState('')\n\n  const full = max !== undefined && value.length >= max\n\n  const commit = useCallback(\n    (raw: string) => {\n      const tag = raw.trim()\n      // Empty, duplicate, or past the cap: nothing to add. The draft is\n      // cleared either way, so Enter always leaves the box ready for the next\n      // word rather than leaving a rejected one sitting there.\n      if (tag && !value.includes(tag) && !full) onValueChange([...value, tag])\n      setDraft('')\n    },\n    [full, onValueChange, value],\n  )\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === 'Enter') {\n      // Only when there is something to commit: otherwise Enter belongs to the\n      // form around this, and swallowing it would break submitting by keyboard.\n      if (draft.trim()) {\n        event.preventDefault()\n        commit(draft)\n      }\n      return\n    }\n    if (event.key === 'Backspace' && draft === '' && value.length > 0) {\n      onValueChange(value.slice(0, -1))\n    }\n  }\n\n  return (\n    <div\n      className={cn(tagInputVariants({ size }), disabled && 'pointer-events-none opacity-50', className)}\n    >\n      {value.map((tag) => (\n        <Chip\n          key={tag}\n          removeLabel={removeLabel(tag)}\n          onRemove={() => onValueChange(value.filter((entry) => entry !== tag))}\n        >\n          {tag}\n        </Chip>\n      ))}\n      <input\n        id={id}\n        value={draft}\n        disabled={disabled}\n        // The list is the value; the box is how you add to it. Announcing the\n        // box as the field would name the wrong thing, so the label goes here\n        // where a screen reader meets it.\n        {...aria}\n        placeholder={full ? undefined : placeholder}\n        onChange={(event) => setDraft(event.target.value)}\n        onKeyDown={onKeyDown}\n        // A word typed and not committed is a word the reader thinks they\n        // entered. Blur is where that belief meets the form's Save button.\n        onBlur={() => commit(draft)}\n        className={tagInputBoxVariants({ size })}\n      />\n      {children}\n    </div>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "textarea",
      "type": "registry:ui",
      "title": "Textarea",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/textarea.tsx",
          "target": "@ui/textarea.tsx",
          "type": "registry:ui",
          "content": "import { useCallback, useLayoutEffect, useRef, type Ref, type TextareaHTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Textarea.\n *\n * A multi-line field that can grow with what is typed into it, which is the\n * only interesting part: a fixed box makes someone scroll inside a scroll,\n * and a box that grows without limit pushes the button they are trying to\n * reach off the screen. `autoResize` grows it; `maxRows` says when to stop\n * and let it scroll after all.\n *\n * The measurement is the usual trick and worth stating: height is reset to\n * `auto` before reading `scrollHeight`, because a box already tall enough\n * reports its own height and never shrinks back.\n */\nexport interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {\n  ref?: Ref<HTMLTextAreaElement>\n  /** Grow to fit the content instead of scrolling. */\n  autoResize?: boolean\n  /** Stop growing here, in lines, and scroll instead. */\n  maxRows?: number\n}\n\nexport function Textarea({\n  className,\n  ref,\n  autoResize = false,\n  maxRows,\n  onChange,\n  ...props\n}: TextareaProps) {\n  const own = useRef<HTMLTextAreaElement>(null)\n\n  const resize = useCallback(() => {\n    const element = own.current\n    if (!element || !autoResize) return\n\n    // Reset first: a box that is already tall enough reports its own height as\n    // `scrollHeight` and would never shrink again.\n    element.style.height = 'auto'\n\n    const styles = getComputedStyle(element)\n    const lineHeight = Number.parseFloat(styles.lineHeight) || 0\n    const vertical =\n      Number.parseFloat(styles.paddingTop) +\n      Number.parseFloat(styles.paddingBottom) +\n      Number.parseFloat(styles.borderTopWidth) +\n      Number.parseFloat(styles.borderBottomWidth)\n\n    const wanted = element.scrollHeight\n    const ceiling = maxRows && lineHeight ? maxRows * lineHeight + vertical : Infinity\n\n    element.style.height = `${Math.min(wanted, ceiling)}px`\n    element.style.overflowY = wanted > ceiling ? 'auto' : 'hidden'\n  }, [autoResize, maxRows])\n\n  // Before paint, so the field never appears at the wrong height and then\n  // jumps - including on the first render, when it may already have a value.\n  useLayoutEffect(resize, [resize, props.value, props.defaultValue])\n\n  return (\n    <textarea\n      ref={(element) => {\n        own.current = element\n        if (typeof ref === 'function') ref(element)\n        else if (ref) ref.current = element\n      }}\n      onChange={(event) => {\n        resize()\n        onChange?.(event)\n      }}\n      className={cn(fieldClasses, autoResize ? 'resize-none' : 'resize-y', className)}\n      {...props}\n    />\n  )\n}\n"
        }
      ]
    },
    {
      "name": "time-field",
      "type": "registry:ui",
      "title": "Time-field",
      "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`.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/input.json"
      ],
      "files": [
        {
          "path": "ui/time-field.tsx",
          "target": "@ui/time-field.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "toast",
      "type": "registry:ui",
      "title": "Toast",
      "description": "For something that already happened and needs no decision. Anything that needs an answer is a dialog - a toast that asks a question is a question the reader can miss by looking away.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/toast.tsx",
          "target": "@ui/toast.tsx",
          "type": "registry:ui",
          "content": "import { Toast as Base } from '@base-ui/react/toast'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Toast.\n *\n * For something that already happened and needs no decision. Anything that\n * needs an answer is a dialog - a toast that asks a question is a question the\n * reader can miss by looking away.\n *\n * The tone is on the toast rather than in five separate components, because\n * the difference between \"saved\" and \"could not save\" is emphasis, never\n * meaning: the sentence says which it is, and a reader who cannot separate\n * green from red gets the same message. The stripe down the side exists for\n * the same reason a badge carries a word.\n *\n * `Toast.Root` is `role=\"dialog\"` inside a `role=\"region\"` viewport, and Base\n * UI drives the live region, the timers, the pause on hover and focus, and the\n * swipe. What is here is the clothes and the vocabulary.\n */\n\nexport const toastVariants = cva(\n  [\n    'relative w-[min(22rem,calc(100vw-2rem))] overflow-hidden',\n    'rounded-lg border border-line bg-raise p-3 pl-4 text-text shadow-float',\n    // The stripe. `before` rather than a border, so the corner radius stays\n    // the panel's own.\n    'before:absolute before:inset-y-0 before:left-0 before:w-1',\n    '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n    'data-[starting-style]:translate-x-4 data-[starting-style]:opacity-0',\n    'data-[ending-style]:translate-x-4 data-[ending-style]:opacity-0',\n  ],\n  {\n    variants: {\n      tone: {\n        neutral: 'before:bg-line-2',\n        good: 'before:bg-good',\n        warn: 'before:bg-warn',\n        bad: 'before:bg-bad',\n        info: 'before:bg-info',\n      },\n    },\n    defaultVariants: { tone: 'neutral' },\n  },\n)\n\n/** Wraps the part of the application that can raise a toast. One per screen;\n * nesting them gives a product two queues that do not know about each other. */\nexport const ToastProvider = Base.Provider\n\n/** The manager: `add`, `update`, `close`, and `promise` for the common case of\n * \"say this while it runs, that when it lands\". */\nexport const useToastManager = Base.useToastManager\n\n/** A toast raised outside React - from a store, an event handler, a worker.\n * Pass the result to `ToastProvider`'s `toastManager`. */\nexport const createToastManager = Base.createToastManager\n\n/** The heading. Base UI points the toast's `aria-labelledby` at it. */\nexport function ToastTitle({ className, ...props }: Base.Title.Props) {\n  return <Base.Title className={cn('text-sm font-semibold', className)} {...props} />\n}\n\n/** The sentence under it, and the toast's `aria-describedby`. */\nexport function ToastDescription({ className, ...props }: Base.Description.Props) {\n  return <Base.Description className={cn('mt-0.5 text-xs text-dim', className)} {...props} />\n}\n\n/** The one thing the reader can do about it: undo, or go and look. */\nexport function ToastAction({ className, ...props }: Base.Action.Props) {\n  return (\n    <Base.Action\n      className={cn(\n        'mt-2 inline-flex h-7 cursor-pointer items-center rounded-md px-2 text-xs font-medium',\n        'bg-accent-soft text-accent transition-colors hover:bg-accent-soft/60',\n        'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\n/** The dismiss button. Needs a word - a bare cross is announced as nothing. */\nexport function ToastClose({ className, ...props }: Base.Close.Props) {\n  return (\n    <Base.Close\n      className={cn(\n        'absolute right-2 top-2 grid size-6 cursor-pointer place-items-center rounded-sm',\n        'text-faint transition-colors hover:bg-soft hover:text-text',\n        'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n        className,\n      )}\n      {...props}\n    >\n      <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n        <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n      </svg>\n    </Base.Close>\n  )\n}\n\nexport interface ToastProps extends Base.Root.Props, VariantProps<typeof toastVariants> {}\n\n/** One toast. The tone comes from the caller or from `toast.type`, so a\n * product that raises them through the manager gets the stripe for free. */\nexport function Toast({ tone, className, toast, ...props }: ToastProps) {\n  const fromType = TONE_FOR_TYPE[toast.type ?? ''] ?? undefined\n  return (\n    <Base.Root\n      toast={toast}\n      className={cn(toastVariants({ tone: tone ?? fromType }), className)}\n      {...props}\n    />\n  )\n}\n\n/** Base UI's own `type` values, mapped onto the vocabulary. A product calling\n * `manager.add({ type: 'success' })` should not also have to say which colour\n * that is. */\nconst TONE_FOR_TYPE: Record<string, 'good' | 'warn' | 'bad' | 'info' | undefined> = {\n  success: 'good',\n  warning: 'warn',\n  error: 'bad',\n  info: 'info',\n  loading: 'info',\n}\n\nexport interface ToastViewportProps extends Base.Viewport.Props {\n  /** Where to portal to. Defaults to the document body, which is what keeps\n   * toasts above everything regardless of where they were raised from. */\n  container?: Base.Portal.Props['container']\n}\n\n/** Where they stack. Bottom right by default, which is the corner that does\n * not cover a form being filled in or a menu being read. */\nexport function ToastViewport({ container, className, ...props }: ToastViewportProps) {\n  return (\n    <Base.Portal container={container}>\n      <Base.Viewport\n        className={cn(\n          'fixed bottom-4 right-4 flex w-[min(22rem,calc(100vw-2rem))] flex-col-reverse gap-2',\n          '[z-index:var(--z-toast)]',\n          className,\n        )}\n        {...props}\n      />\n    </Base.Portal>\n  )\n}\n"
        }
      ]
    },
    {
      "name": "tooltip",
      "type": "registry:ui",
      "title": "Tooltip",
      "description": "A label for a control that has no room for one - an icon button, a truncated cell, a symbol whose meaning is not obvious. A phrase, not a panel.",
      "dependencies": [
        "@base-ui/react",
        "class-variance-authority",
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/tooltip.tsx",
          "target": "@ui/tooltip.tsx",
          "type": "registry:ui",
          "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"
        }
      ]
    },
    {
      "name": "truncate",
      "type": "registry:ui",
      "title": "Truncate",
      "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.",
      "dependencies": [
        "dowel-ui@^0.20.3"
      ],
      "registryDependencies": [],
      "files": [
        {
          "path": "ui/truncate.tsx",
          "target": "@ui/truncate.tsx",
          "type": "registry:ui",
          "content": "import type { HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Truncate.\n *\n * Text that does not fit, cut with an ellipsis - and, importantly, still\n * readable in full: the element carries its own text as a `title`, so hovering\n * shows what was cut. Every product wrote the one-line version of this and\n * none of them remembered the title.\n *\n * `lines` truncates after that many instead of one, which needs a different\n * mechanism (`line-clamp`) rather than a different value.\n */\nexport interface TruncateProps extends HTMLAttributes<HTMLSpanElement> {\n  /** The text. A string, because the component has to be able to put it in a\n   * `title` - arbitrary children could not be. */\n  children: string\n  /** Cut after this many lines. One by default. */\n  lines?: number\n  /** Say what the full text is on hover. On by default; turn it off where the\n   * text is already visible elsewhere, or the tooltip is noise. */\n  title?: string | undefined\n}\n\nexport function Truncate({ children, lines = 1, className, title, ...props }: TruncateProps) {\n  return (\n    <span\n      // The browser shows this only when the text is actually cut, so it costs\n      // nothing when everything fits.\n      title={title ?? children}\n      className={cn(\n        lines === 1 ? 'block truncate' : 'block overflow-hidden',\n        className,\n      )}\n      style={\n        lines > 1\n          ? { display: '-webkit-box', WebkitLineClamp: lines, WebkitBoxOrient: 'vertical' }\n          : undefined\n      }\n      {...props}\n    >\n      {children}\n    </span>\n  )\n}\n"
        }
      ]
    },
    {
      "extends": "none",
      "name": "app",
      "type": "registry:style",
      "title": "dowel app",
      "description": "The set a product of the line actually starts from: the everyday controls, the overlays it needs on day one, and the ways of choosing something. What kilna has installed.",
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/badge.json",
        "https://lacodda.github.io/dowel/r/button.json",
        "https://lacodda.github.io/dowel/r/combobox.json",
        "https://lacodda.github.io/dowel/r/confirm-dialog.json",
        "https://lacodda.github.io/dowel/r/dialog.json",
        "https://lacodda.github.io/dowel/r/drawer.json",
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/kbd.json",
        "https://lacodda.github.io/dowel/r/menu.json",
        "https://lacodda.github.io/dowel/r/panel.json",
        "https://lacodda.github.io/dowel/r/select.json",
        "https://lacodda.github.io/dowel/r/textarea.json",
        "https://lacodda.github.io/dowel/r/toast.json"
      ],
      "docs": "Import the theme and your product accent first; these are the components on top of it."
    },
    {
      "extends": "none",
      "name": "forms",
      "type": "registry:style",
      "title": "dowel forms",
      "description": "What a form is made of: the fields, the two ways of choosing from a list, and the button that submits it.",
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/action-bar.json",
        "https://lacodda.github.io/dowel/r/button.json",
        "https://lacodda.github.io/dowel/r/checkbox.json",
        "https://lacodda.github.io/dowel/r/chip.json",
        "https://lacodda.github.io/dowel/r/color-field.json",
        "https://lacodda.github.io/dowel/r/combobox.json",
        "https://lacodda.github.io/dowel/r/duration-field.json",
        "https://lacodda.github.io/dowel/r/field.json",
        "https://lacodda.github.io/dowel/r/file-drop.json",
        "https://lacodda.github.io/dowel/r/input.json",
        "https://lacodda.github.io/dowel/r/number-field.json",
        "https://lacodda.github.io/dowel/r/password-field.json",
        "https://lacodda.github.io/dowel/r/radio-group.json",
        "https://lacodda.github.io/dowel/r/save-state.json",
        "https://lacodda.github.io/dowel/r/select.json",
        "https://lacodda.github.io/dowel/r/spinner.json",
        "https://lacodda.github.io/dowel/r/switch.json",
        "https://lacodda.github.io/dowel/r/tag-input.json",
        "https://lacodda.github.io/dowel/r/textarea.json"
      ],
      "docs": "Field wraps any of the controls: it is what ties a label, a hint and an error to the thing they belong to. Slider and RatingScale are not in this set - install them where the question calls for one."
    },
    {
      "extends": "none",
      "name": "feedback",
      "type": "registry:style",
      "title": "dowel feedback",
      "description": "The three ways of saying that something happened - one that goes away, one that is still true after a reload, one that is true on every screen - and the dialog for anything that needs an answer.",
      "registryDependencies": [
        "https://lacodda.github.io/dowel/r/alert.json",
        "https://lacodda.github.io/dowel/r/banner.json",
        "https://lacodda.github.io/dowel/r/confirm-dialog.json",
        "https://lacodda.github.io/dowel/r/toast.json"
      ],
      "docs": "Which of the four to reach for is the harder question: see https://lacodda.github.io/dowel/guides/overlays/"
    },
    {
      "name": "agents",
      "type": "registry:file",
      "title": "Agent briefing",
      "description": "What an agent working in a product on dowel has to know: that components are copied rather than installed, that no colour is ever written down, and where to look things up. Lands at the project root as AGENTS.md.",
      "files": [
        {
          "path": "files/AGENTS.md",
          "target": "~/AGENTS.md",
          "type": "registry:file",
          "content": "# Working on the UI of this project\n\nThis project's interface is built on [dowel](https://lacodda.github.io/dowel),\nthe lacodda line design system. What follows is what an agent — or a person new\nto the codebase — has to know before touching a component.\n\n## Where the components come from\n\nThey were **copied in**, not installed. `npx shadcn add <url>` writes a file\ninto `components/ui/` and from that moment it belongs to this project.\n\n- Editing one is normal. It is this project's file.\n- Re-running `add` for a component **overwrites it, edits included**. There is\n  no merge. Before re-adding, check whether the local copy was changed.\n- Adding a new one: `npx shadcn@latest add https://lacodda.github.io/dowel/r/<name>.json`\n\nThe catalogue of what exists is at\n[`/r/registry.json`](https://lacodda.github.io/dowel/r/registry.json), and also\noffline at `node_modules/dowel-ui/registry.json` — the version this project\nactually has. Check there before writing a component from scratch.\n\n## The rules that are not negotiable here\n\n**No colour is ever written down.** Not a hex, not `rgb()`, not a stock Tailwind\ncolour like `zinc-800`, not `bg-white`. Every colour goes through a token:\n`bg-bg`, `bg-raise`, `text-text`, `text-dim`, `border-line`, `bg-accent`,\n`text-on-accent`. The full vocabulary:\n<https://lacodda.github.io/dowel/reference/tokens/>\n\n**No `dark:` utility.** A component does not know which theme it is in — the\ntheme swaps the token underneath. A `dark:` in a component means a token is\nmissing; that is the thing to fix.\n\n*(A translucent black or white is not a colour but a veil: `bg-black/50` over an\nimage is fine.)*\n\n**No native `<select>`.** Use the `Select` or `Combobox` component. The browser\ndraws a native select's popup in the operating system's own chrome, where no\nstylesheet reaches.\n\nBoth rules are enforced by ESLint (`dowel-ui/eslint`), so a violation fails the\nlint, not a review.\n\n## The theme\n\nOne import, plus this product's own colour:\n\n```css\n@import 'tailwindcss';\n@import 'dowel-ui/theme.css';\n@import 'dowel-ui/accents/<product>.css';\n```\n\nEverything else is derived from that one accent — the hover shade, the soft\nfill, the focus ring, and what colour text has to be on an accent fill. **Do not\noverride a derived token** (`--on-accent` especially): it is calculated for\ncontrast, and setting it by hand is how unreadable buttons ship.\n\n## Where to look things up\n\n- Components, one page each: <https://lacodda.github.io/dowel/components/button/>\n- Every component live, in both themes: <https://lacodda.github.io/dowel/stand/>\n- The words this system uses: <https://lacodda.github.io/dowel/concepts/vocabulary/>\n- Mistakes that are actually made against it: <https://lacodda.github.io/dowel/concepts/anti-patterns/>\n- For a machine reader: <https://lacodda.github.io/dowel/llms.txt>, and any page\n  as Markdown by appending `.md` to its URL.\n\n## What to do when a component does not fit\n\nIn order:\n\n1. **Check its parts.** dowel exposes them (`DialogPopup`, `DialogTitle`,\n   `DialogActions`) rather than hiding them behind props. Most \"it does not fit\"\n   is a part that was not reached for.\n2. **Wrap it.** A local wrapper that fixes this product's conventions around a\n   dowel component keeps the component upgradable.\n3. **Edit the copy.** It is this project's file. Note that the next `add` will\n   overwrite it.\n\nWriting a second component that does the same thing is the option that costs\nlater, and it is the one that happens by default.\n"
        }
      ],
      "docs": "Replace `<product>` in the accent import with your own, and add whatever else your project expects of an agent."
    }
  ]
}
