dowel-ui 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,21 @@ your code:
83
83
  npx shadcn@latest add https://lacodda.github.io/dowel/r/button.json
84
84
  ```
85
85
 
86
+ Or the set a product usually starts from, in one command - `app`, `forms` and
87
+ `feedback` are three sets read off what the line's products converged on:
88
+
89
+ ```bash
90
+ npx shadcn@latest add https://lacodda.github.io/dowel/r/app.json
91
+ ```
92
+
93
+ A set carries no files of its own: it resolves into the same per-component
94
+ installs you could have typed, so nothing of it survives in your project and
95
+ there is no membership to leave. Each minor of the registry is also served
96
+ frozen at `r/v0.12/…`, for an install that has to be repeatable - inside a
97
+ snapshot the cross-references point into the same snapshot, so a component and
98
+ the sibling it reuses are the pair that shipped together. See
99
+ [installing from the registry](https://lacodda.github.io/dowel/guides/registry/).
100
+
86
101
  Twenty-six of them so far. The everyday ones - Button, Input, Textarea, Panel,
87
102
  Badge, Chip, Kbd, Spinner, Truncate and Copyable; the six that float above the
88
103
  page - Dialog, ConfirmDialog, Drawer, Popover, PreviewCard and Tooltip; four
@@ -160,6 +175,22 @@ Development goes in versions; each one is a single coherent theme, and ends in a
160
175
  - [Getting started](https://lacodda.github.io/dowel/getting-started/), [the lint rules](https://lacodda.github.io/dowel/guides/linting/) and [what a component has to pass](https://lacodda.github.io/dowel/guides/gates/)
161
176
  - [Components](https://lacodda.github.io/dowel/components/button/)
162
177
  - [Tokens](https://lacodda.github.io/dowel/reference/tokens/), [scales](https://lacodda.github.io/dowel/reference/scales/) and [accents](https://lacodda.github.io/dowel/reference/accents/)
178
+ - [The vocabulary](https://lacodda.github.io/dowel/concepts/vocabulary/) this system uses for its own parts, and [the mistakes](https://lacodda.github.io/dowel/concepts/anti-patterns/) that actually get made against it
179
+
180
+ ## For a machine
181
+
182
+ The documentation is also served in the form an agent reads, generated from the
183
+ same sources the site is built from:
184
+
185
+ - [llms.txt](https://lacodda.github.io/dowel/llms.txt) - the index, and
186
+ [llms-full.txt](https://lacodda.github.io/dowel/llms-full.txt) for all of it
187
+ in one request
188
+ - any page as plain Markdown, by appending `.md` to its URL
189
+ - [a JSON Schema](https://lacodda.github.io/dowel/r/schema.json) of a registry
190
+ item, validated against every item the registry serves
191
+ - an [`AGENTS.md`](https://lacodda.github.io/dowel/r/agents.json) a consumer
192
+ installs like any other item, so a product on dowel can tell its own agents
193
+ what its code cannot
163
194
 
164
195
  ## License
165
196
 
@@ -0,0 +1,800 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry.json",
3
+ "name": "dowel",
4
+ "homepage": "https://lacodda.github.io/dowel",
5
+ "items": [
6
+ {
7
+ "extends": "none",
8
+ "name": "theme",
9
+ "type": "registry:style",
10
+ "title": "dowel theme",
11
+ "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.",
12
+ "files": [
13
+ {
14
+ "path": "dowel/theme.css",
15
+ "target": "~/dowel/theme.css",
16
+ "type": "registry:file",
17
+ "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. `raise` keeps its original value, so nothing shifts under\n * the products already using it; the other two are the steps either side. */\n --shadow-lift: 0 2px 8px rgb(0 0 0 / 0.3);\n --shadow-raise: 0 10px 34px rgb(0 0 0 / 0.45);\n --shadow-float: 0 24px 60px rgb(0 0 0 / 0.55);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
18
+ }
19
+ ],
20
+ "docs": "Import the theme, then your product accent:\n\n @import './dowel/theme.css';\n @import './dowel/accents/kilna.css';\n\nOutside the line, set the colour directly instead:\n\n :root { --accent-base: #2f7d6b; }"
21
+ },
22
+ {
23
+ "name": "accent-kasl",
24
+ "type": "registry:file",
25
+ "title": "kasl accent",
26
+ "description": "lime #A9C23F - the colour of the kasl mark (ka). Import after the theme.",
27
+ "files": [
28
+ {
29
+ "path": "dowel/accents/kasl.css",
30
+ "target": "~/dowel/accents/kasl.css",
31
+ "type": "registry:file",
32
+ "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"
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "name": "accent-kasl-server",
38
+ "type": "registry:file",
39
+ "title": "kasl-server accent",
40
+ "description": "gold #D9A82E - the colour of the kasl-server mark (ks). Import after the theme.",
41
+ "files": [
42
+ {
43
+ "path": "dowel/accents/kasl-server.css",
44
+ "target": "~/dowel/accents/kasl-server.css",
45
+ "type": "registry:file",
46
+ "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"
47
+ }
48
+ ]
49
+ },
50
+ {
51
+ "name": "accent-turnout",
52
+ "type": "registry:file",
53
+ "title": "turnout accent",
54
+ "description": "rose #E85B72 - the colour of the turnout mark (tn). Import after the theme.",
55
+ "files": [
56
+ {
57
+ "path": "dowel/accents/turnout.css",
58
+ "target": "~/dowel/accents/turnout.css",
59
+ "type": "registry:file",
60
+ "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"
61
+ }
62
+ ]
63
+ },
64
+ {
65
+ "name": "accent-sefy",
66
+ "type": "registry:file",
67
+ "title": "sefy accent",
68
+ "description": "teal #35A8A0 - the colour of the sefy mark (se). Import after the theme.",
69
+ "files": [
70
+ {
71
+ "path": "dowel/accents/sefy.css",
72
+ "target": "~/dowel/accents/sefy.css",
73
+ "type": "registry:file",
74
+ "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"
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "name": "accent-atlas",
80
+ "type": "registry:file",
81
+ "title": "atlas accent",
82
+ "description": "nebula #8A7DF5 - the colour of the atlas mark (at). Import after the theme.",
83
+ "files": [
84
+ {
85
+ "path": "dowel/accents/atlas.css",
86
+ "target": "~/dowel/accents/atlas.css",
87
+ "type": "registry:file",
88
+ "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"
89
+ }
90
+ ]
91
+ },
92
+ {
93
+ "name": "accent-nitid",
94
+ "type": "registry:file",
95
+ "title": "nitid accent",
96
+ "description": "cyan #3FA9D9 - the colour of the nitid mark (nd). Import after the theme.",
97
+ "files": [
98
+ {
99
+ "path": "dowel/accents/nitid.css",
100
+ "target": "~/dowel/accents/nitid.css",
101
+ "type": "registry:file",
102
+ "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"
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "name": "accent-midda",
108
+ "type": "registry:file",
109
+ "title": "midda accent",
110
+ "description": "violet #A46BE8 - the colour of the midda mark (mi). Import after the theme.",
111
+ "files": [
112
+ {
113
+ "path": "dowel/accents/midda.css",
114
+ "target": "~/dowel/accents/midda.css",
115
+ "type": "registry:file",
116
+ "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"
117
+ }
118
+ ]
119
+ },
120
+ {
121
+ "name": "accent-nooma",
122
+ "type": "registry:file",
123
+ "title": "nooma accent",
124
+ "description": "emerald #3FA873 - the colour of the nooma mark (nm). Import after the theme.",
125
+ "files": [
126
+ {
127
+ "path": "dowel/accents/nooma.css",
128
+ "target": "~/dowel/accents/nooma.css",
129
+ "type": "registry:file",
130
+ "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"
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ "name": "accent-kilna",
136
+ "type": "registry:file",
137
+ "title": "kilna accent",
138
+ "description": "magenta #D9569E - the colour of the kilna mark (ki). Import after the theme.",
139
+ "files": [
140
+ {
141
+ "path": "dowel/accents/kilna.css",
142
+ "target": "~/dowel/accents/kilna.css",
143
+ "type": "registry:file",
144
+ "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"
145
+ }
146
+ ]
147
+ },
148
+ {
149
+ "name": "accent-lyrid",
150
+ "type": "registry:file",
151
+ "title": "lyrid accent",
152
+ "description": "azure #4A8FE8 - the colour of the lyrid mark (ly). Import after the theme.",
153
+ "files": [
154
+ {
155
+ "path": "dowel/accents/lyrid.css",
156
+ "target": "~/dowel/accents/lyrid.css",
157
+ "type": "registry:file",
158
+ "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"
159
+ }
160
+ ]
161
+ },
162
+ {
163
+ "name": "accent-efema",
164
+ "type": "registry:file",
165
+ "title": "efema accent",
166
+ "description": "cobalt #5470E8 - the colour of the efema mark (ef). Import after the theme.",
167
+ "files": [
168
+ {
169
+ "path": "dowel/accents/efema.css",
170
+ "target": "~/dowel/accents/efema.css",
171
+ "type": "registry:file",
172
+ "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"
173
+ }
174
+ ]
175
+ },
176
+ {
177
+ "name": "accent-dowel",
178
+ "type": "registry:file",
179
+ "title": "dowel accent",
180
+ "description": "signal amber #E8862D - the colour of the dowel mark (dw). Import after the theme.",
181
+ "files": [
182
+ {
183
+ "path": "dowel/accents/dowel.css",
184
+ "target": "~/dowel/accents/dowel.css",
185
+ "type": "registry:file",
186
+ "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"
187
+ }
188
+ ]
189
+ },
190
+ {
191
+ "name": "accent-lyrn",
192
+ "type": "registry:file",
193
+ "title": "lyrn accent",
194
+ "description": "indigo #6D7BF2 - the colour of the lyrn mark (ln). Import after the theme.",
195
+ "files": [
196
+ {
197
+ "path": "dowel/accents/lyrn.css",
198
+ "target": "~/dowel/accents/lyrn.css",
199
+ "type": "registry:file",
200
+ "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"
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "name": "accent-austeris",
206
+ "type": "registry:file",
207
+ "title": "austeris accent",
208
+ "description": "orchid #C25BD9 - the colour of the austeris mark (au). Import after the theme.",
209
+ "files": [
210
+ {
211
+ "path": "dowel/accents/austeris.css",
212
+ "target": "~/dowel/accents/austeris.css",
213
+ "type": "registry:file",
214
+ "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"
215
+ }
216
+ ]
217
+ },
218
+ {
219
+ "name": "alert",
220
+ "type": "registry:ui",
221
+ "title": "Alert",
222
+ "description": "A message that stays on the screen, in the flow of the page, about the thing next to it: this field could not be saved, this profile has no axes yet, this export is out of date.",
223
+ "dependencies": [
224
+ "class-variance-authority",
225
+ "dowel-ui@^0.13.0"
226
+ ],
227
+ "registryDependencies": [],
228
+ "files": [
229
+ {
230
+ "path": "ui/alert.tsx",
231
+ "target": "@ui/alert.tsx",
232
+ "type": "registry:ui",
233
+ "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"
234
+ }
235
+ ]
236
+ },
237
+ {
238
+ "name": "badge",
239
+ "type": "registry:ui",
240
+ "title": "Badge",
241
+ "description": "A small piece of state attached to something else: a count, a status, a label. It is not a button and never was - if it can be clicked it is a Chip.",
242
+ "dependencies": [
243
+ "class-variance-authority",
244
+ "dowel-ui@^0.13.0"
245
+ ],
246
+ "registryDependencies": [],
247
+ "files": [
248
+ {
249
+ "path": "ui/badge.tsx",
250
+ "target": "@ui/badge.tsx",
251
+ "type": "registry:ui",
252
+ "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"
253
+ }
254
+ ]
255
+ },
256
+ {
257
+ "name": "banner",
258
+ "type": "registry:ui",
259
+ "title": "Banner",
260
+ "description": "A strip across the top of the application, about the application: you are offline, this build is a preview, your licence expires on Friday, a new version is ready to install.",
261
+ "dependencies": [
262
+ "class-variance-authority",
263
+ "dowel-ui@^0.13.0"
264
+ ],
265
+ "registryDependencies": [],
266
+ "files": [
267
+ {
268
+ "path": "ui/banner.tsx",
269
+ "target": "@ui/banner.tsx",
270
+ "type": "registry:ui",
271
+ "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"
272
+ }
273
+ ]
274
+ },
275
+ {
276
+ "name": "button",
277
+ "type": "registry:ui",
278
+ "title": "Button",
279
+ "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.",
280
+ "dependencies": [
281
+ "@base-ui/react",
282
+ "class-variance-authority",
283
+ "dowel-ui@^0.13.0"
284
+ ],
285
+ "registryDependencies": [],
286
+ "files": [
287
+ {
288
+ "path": "ui/button.tsx",
289
+ "target": "@ui/button.tsx",
290
+ "type": "registry:ui",
291
+ "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"
292
+ }
293
+ ]
294
+ },
295
+ {
296
+ "name": "chip",
297
+ "type": "registry:ui",
298
+ "title": "Chip",
299
+ "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.",
300
+ "dependencies": [
301
+ "class-variance-authority",
302
+ "dowel-ui@^0.13.0"
303
+ ],
304
+ "registryDependencies": [],
305
+ "files": [
306
+ {
307
+ "path": "ui/chip.tsx",
308
+ "target": "@ui/chip.tsx",
309
+ "type": "registry:ui",
310
+ "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"
311
+ }
312
+ ]
313
+ },
314
+ {
315
+ "name": "combobox",
316
+ "type": "registry:ui",
317
+ "title": "Combobox",
318
+ "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.",
319
+ "dependencies": [
320
+ "@base-ui/react",
321
+ "class-variance-authority",
322
+ "dowel-ui@^0.13.0"
323
+ ],
324
+ "registryDependencies": [
325
+ "https://lacodda.github.io/dowel/r/input.json",
326
+ "https://lacodda.github.io/dowel/r/select.json"
327
+ ],
328
+ "files": [
329
+ {
330
+ "path": "ui/combobox.tsx",
331
+ "target": "@ui/combobox.tsx",
332
+ "type": "registry:ui",
333
+ "content": "import { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\nexport const comboboxInputVariants = cva([fieldClasses], {\n variants: {\n size: {\n sm: 'h-8 text-xs',\n md: 'h-9',\n lg: 'h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it. */\nexport const ComboboxChips = Base.Chips\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`. */\nexport function ComboboxInput({ size, className, ...props }: ComboboxInputProps) {\n return <Base.Input className={cn(comboboxInputVariants({ size }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
334
+ }
335
+ ]
336
+ },
337
+ {
338
+ "name": "command-palette",
339
+ "type": "registry:ui",
340
+ "title": "Command-palette",
341
+ "description": "One box that finds anything: the shortcut opens it, typing narrows a list, Enter runs what is highlighted.",
342
+ "dependencies": [
343
+ "@base-ui/react",
344
+ "class-variance-authority",
345
+ "dowel-ui@^0.13.0"
346
+ ],
347
+ "registryDependencies": [
348
+ "https://lacodda.github.io/dowel/r/combobox.json",
349
+ "https://lacodda.github.io/dowel/r/kbd.json"
350
+ ],
351
+ "files": [
352
+ {
353
+ "path": "ui/command-palette.tsx",
354
+ "target": "@ui/command-palette.tsx",
355
+ "type": "registry:ui",
356
+ "content": "import type { ReactNode } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n md: 'max-h-[24rem]',\n lg: 'max-h-[32rem]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ. */\nexport const CommandPaletteItem = Base.Item\n\n/** The list. Takes a render function over the filtered items. */\nexport const CommandPaletteList = Base.List\n\n/** Shown when nothing matches. The words are the product's. */\nexport const CommandPaletteEmpty = Base.Empty\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n // Anchored to the viewport rather than to a trigger: a palette has no\n // trigger to point at, and the one it does have is a keystroke.\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `['Esc']`. Decorative. */\n hint?: string[]\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n 'h-11 w-full bg-transparent text-sm text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
357
+ }
358
+ ]
359
+ },
360
+ {
361
+ "name": "confirm-dialog",
362
+ "type": "registry:ui",
363
+ "title": "Confirm-dialog",
364
+ "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.",
365
+ "dependencies": [
366
+ "@base-ui/react",
367
+ "class-variance-authority",
368
+ "dowel-ui@^0.13.0"
369
+ ],
370
+ "registryDependencies": [],
371
+ "files": [
372
+ {
373
+ "path": "ui/confirm-dialog.tsx",
374
+ "target": "@ui/confirm-dialog.tsx",
375
+ "type": "registry:ui",
376
+ "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"
377
+ }
378
+ ]
379
+ },
380
+ {
381
+ "name": "context-menu",
382
+ "type": "registry:ui",
383
+ "title": "Context-menu",
384
+ "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>`.",
385
+ "dependencies": [
386
+ "@base-ui/react",
387
+ "dowel-ui@^0.13.0"
388
+ ],
389
+ "registryDependencies": [
390
+ "https://lacodda.github.io/dowel/r/menu.json"
391
+ ],
392
+ "files": [
393
+ {
394
+ "path": "ui/context-menu.tsx",
395
+ "target": "@ui/context-menu.tsx",
396
+ "type": "registry:ui",
397
+ "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"
398
+ }
399
+ ]
400
+ },
401
+ {
402
+ "name": "copyable",
403
+ "type": "registry:ui",
404
+ "title": "Copyable",
405
+ "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.",
406
+ "dependencies": [
407
+ "dowel-ui@^0.13.0"
408
+ ],
409
+ "registryDependencies": [],
410
+ "files": [
411
+ {
412
+ "path": "ui/copyable.tsx",
413
+ "target": "@ui/copyable.tsx",
414
+ "type": "registry:ui",
415
+ "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"
416
+ }
417
+ ]
418
+ },
419
+ {
420
+ "name": "dialog",
421
+ "type": "registry:ui",
422
+ "title": "Dialog",
423
+ "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.",
424
+ "dependencies": [
425
+ "@base-ui/react",
426
+ "class-variance-authority",
427
+ "dowel-ui@^0.13.0"
428
+ ],
429
+ "registryDependencies": [],
430
+ "files": [
431
+ {
432
+ "path": "ui/dialog.tsx",
433
+ "target": "@ui/dialog.tsx",
434
+ "type": "registry:ui",
435
+ "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"
436
+ }
437
+ ]
438
+ },
439
+ {
440
+ "name": "drawer",
441
+ "type": "registry:ui",
442
+ "title": "Drawer",
443
+ "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.",
444
+ "dependencies": [
445
+ "@base-ui/react",
446
+ "class-variance-authority",
447
+ "dowel-ui@^0.13.0"
448
+ ],
449
+ "registryDependencies": [],
450
+ "files": [
451
+ {
452
+ "path": "ui/drawer.tsx",
453
+ "target": "@ui/drawer.tsx",
454
+ "type": "registry:ui",
455
+ "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"
456
+ }
457
+ ]
458
+ },
459
+ {
460
+ "name": "input",
461
+ "type": "registry:ui",
462
+ "title": "Input",
463
+ "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.",
464
+ "dependencies": [
465
+ "dowel-ui@^0.13.0"
466
+ ],
467
+ "registryDependencies": [],
468
+ "files": [
469
+ {
470
+ "path": "ui/input.tsx",
471
+ "target": "@ui/input.tsx",
472
+ "type": "registry:ui",
473
+ "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"
474
+ }
475
+ ]
476
+ },
477
+ {
478
+ "name": "kbd",
479
+ "type": "registry:ui",
480
+ "title": "Kbd",
481
+ "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.",
482
+ "dependencies": [
483
+ "dowel-ui@^0.13.0"
484
+ ],
485
+ "registryDependencies": [],
486
+ "files": [
487
+ {
488
+ "path": "ui/kbd.tsx",
489
+ "target": "@ui/kbd.tsx",
490
+ "type": "registry:ui",
491
+ "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"
492
+ }
493
+ ]
494
+ },
495
+ {
496
+ "name": "menu",
497
+ "type": "registry:ui",
498
+ "title": "Menu",
499
+ "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.",
500
+ "dependencies": [
501
+ "@base-ui/react",
502
+ "class-variance-authority",
503
+ "dowel-ui@^0.13.0"
504
+ ],
505
+ "registryDependencies": [],
506
+ "files": [
507
+ {
508
+ "path": "ui/menu.tsx",
509
+ "target": "@ui/menu.tsx",
510
+ "type": "registry:ui",
511
+ "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"
512
+ }
513
+ ]
514
+ },
515
+ {
516
+ "name": "panel",
517
+ "type": "registry:ui",
518
+ "title": "Panel",
519
+ "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.",
520
+ "dependencies": [
521
+ "class-variance-authority",
522
+ "dowel-ui@^0.13.0"
523
+ ],
524
+ "registryDependencies": [],
525
+ "files": [
526
+ {
527
+ "path": "ui/panel.tsx",
528
+ "target": "@ui/panel.tsx",
529
+ "type": "registry:ui",
530
+ "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"
531
+ }
532
+ ]
533
+ },
534
+ {
535
+ "name": "popover",
536
+ "type": "registry:ui",
537
+ "title": "Popover",
538
+ "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.",
539
+ "dependencies": [
540
+ "@base-ui/react",
541
+ "class-variance-authority",
542
+ "dowel-ui@^0.13.0"
543
+ ],
544
+ "registryDependencies": [],
545
+ "files": [
546
+ {
547
+ "path": "ui/popover.tsx",
548
+ "target": "@ui/popover.tsx",
549
+ "type": "registry:ui",
550
+ "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"
551
+ }
552
+ ]
553
+ },
554
+ {
555
+ "name": "preview-card",
556
+ "type": "registry:ui",
557
+ "title": "Preview-card",
558
+ "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.",
559
+ "dependencies": [
560
+ "@base-ui/react",
561
+ "class-variance-authority",
562
+ "dowel-ui@^0.13.0"
563
+ ],
564
+ "registryDependencies": [],
565
+ "files": [
566
+ {
567
+ "path": "ui/preview-card.tsx",
568
+ "target": "@ui/preview-card.tsx",
569
+ "type": "registry:ui",
570
+ "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"
571
+ }
572
+ ]
573
+ },
574
+ {
575
+ "name": "search-field",
576
+ "type": "registry:ui",
577
+ "title": "Search-field",
578
+ "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.",
579
+ "dependencies": [
580
+ "dowel-ui@^0.13.0"
581
+ ],
582
+ "registryDependencies": [
583
+ "https://lacodda.github.io/dowel/r/input.json",
584
+ "https://lacodda.github.io/dowel/r/kbd.json",
585
+ "https://lacodda.github.io/dowel/r/shortcut.json"
586
+ ],
587
+ "files": [
588
+ {
589
+ "path": "ui/search-field.tsx",
590
+ "target": "@ui/search-field.tsx",
591
+ "type": "registry:ui",
592
+ "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"
593
+ }
594
+ ]
595
+ },
596
+ {
597
+ "name": "select",
598
+ "type": "registry:ui",
599
+ "title": "Select",
600
+ "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.",
601
+ "dependencies": [
602
+ "@base-ui/react",
603
+ "class-variance-authority",
604
+ "dowel-ui@^0.13.0"
605
+ ],
606
+ "registryDependencies": [
607
+ "https://lacodda.github.io/dowel/r/input.json"
608
+ ],
609
+ "files": [
610
+ {
611
+ "path": "ui/select.tsx",
612
+ "target": "@ui/select.tsx",
613
+ "type": "registry:ui",
614
+ "content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-8 text-xs',\n md: 'h-9',\n lg: 'h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const selectPopupVariants = cva(\n [\n 'max-h-[min(24rem,var(--available-height))] overflow-y-auto',\n 'rounded-md border border-line bg-raise p-1 text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n 'data-[selected]:text-text',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in. */\nexport const SelectValue = Base.Value\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the list\n * from being clipped by a form with `overflow: hidden`. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option. */\nexport function SelectItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(selectItemVariants(), className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
615
+ }
616
+ ]
617
+ },
618
+ {
619
+ "name": "shortcut",
620
+ "type": "registry:ui",
621
+ "title": "Shortcut",
622
+ "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.",
623
+ "dependencies": [],
624
+ "registryDependencies": [],
625
+ "files": [
626
+ {
627
+ "path": "ui/shortcut.tsx",
628
+ "target": "@ui/shortcut.tsx",
629
+ "type": "registry:ui",
630
+ "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"
631
+ }
632
+ ]
633
+ },
634
+ {
635
+ "name": "spinner",
636
+ "type": "registry:ui",
637
+ "title": "Spinner",
638
+ "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.",
639
+ "dependencies": [
640
+ "class-variance-authority",
641
+ "dowel-ui@^0.13.0"
642
+ ],
643
+ "registryDependencies": [],
644
+ "files": [
645
+ {
646
+ "path": "ui/spinner.tsx",
647
+ "target": "@ui/spinner.tsx",
648
+ "type": "registry:ui",
649
+ "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"
650
+ }
651
+ ]
652
+ },
653
+ {
654
+ "name": "textarea",
655
+ "type": "registry:ui",
656
+ "title": "Textarea",
657
+ "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.",
658
+ "dependencies": [
659
+ "dowel-ui@^0.13.0"
660
+ ],
661
+ "registryDependencies": [
662
+ "https://lacodda.github.io/dowel/r/input.json"
663
+ ],
664
+ "files": [
665
+ {
666
+ "path": "ui/textarea.tsx",
667
+ "target": "@ui/textarea.tsx",
668
+ "type": "registry:ui",
669
+ "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"
670
+ }
671
+ ]
672
+ },
673
+ {
674
+ "name": "toast",
675
+ "type": "registry:ui",
676
+ "title": "Toast",
677
+ "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.",
678
+ "dependencies": [
679
+ "@base-ui/react",
680
+ "class-variance-authority",
681
+ "dowel-ui@^0.13.0"
682
+ ],
683
+ "registryDependencies": [],
684
+ "files": [
685
+ {
686
+ "path": "ui/toast.tsx",
687
+ "target": "@ui/toast.tsx",
688
+ "type": "registry:ui",
689
+ "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"
690
+ }
691
+ ]
692
+ },
693
+ {
694
+ "name": "tooltip",
695
+ "type": "registry:ui",
696
+ "title": "Tooltip",
697
+ "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.",
698
+ "dependencies": [
699
+ "@base-ui/react",
700
+ "class-variance-authority",
701
+ "dowel-ui@^0.13.0"
702
+ ],
703
+ "registryDependencies": [],
704
+ "files": [
705
+ {
706
+ "path": "ui/tooltip.tsx",
707
+ "target": "@ui/tooltip.tsx",
708
+ "type": "registry:ui",
709
+ "content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-float',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-[3px]',\n 'data-[side=top]:-bottom-[3px]',\n 'data-[side=left]:-right-[3px]',\n 'data-[side=right]:-left-[3px]',\n className,\n )}\n {...props}\n />\n )\n}\n"
710
+ }
711
+ ]
712
+ },
713
+ {
714
+ "name": "truncate",
715
+ "type": "registry:ui",
716
+ "title": "Truncate",
717
+ "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.",
718
+ "dependencies": [
719
+ "dowel-ui@^0.13.0"
720
+ ],
721
+ "registryDependencies": [],
722
+ "files": [
723
+ {
724
+ "path": "ui/truncate.tsx",
725
+ "target": "@ui/truncate.tsx",
726
+ "type": "registry:ui",
727
+ "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"
728
+ }
729
+ ]
730
+ },
731
+ {
732
+ "extends": "none",
733
+ "name": "app",
734
+ "type": "registry:style",
735
+ "title": "dowel app",
736
+ "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.",
737
+ "registryDependencies": [
738
+ "https://lacodda.github.io/dowel/r/badge.json",
739
+ "https://lacodda.github.io/dowel/r/button.json",
740
+ "https://lacodda.github.io/dowel/r/combobox.json",
741
+ "https://lacodda.github.io/dowel/r/confirm-dialog.json",
742
+ "https://lacodda.github.io/dowel/r/dialog.json",
743
+ "https://lacodda.github.io/dowel/r/drawer.json",
744
+ "https://lacodda.github.io/dowel/r/input.json",
745
+ "https://lacodda.github.io/dowel/r/kbd.json",
746
+ "https://lacodda.github.io/dowel/r/menu.json",
747
+ "https://lacodda.github.io/dowel/r/panel.json",
748
+ "https://lacodda.github.io/dowel/r/select.json",
749
+ "https://lacodda.github.io/dowel/r/textarea.json",
750
+ "https://lacodda.github.io/dowel/r/toast.json"
751
+ ],
752
+ "docs": "Import the theme and your product accent first; these are the components on top of it."
753
+ },
754
+ {
755
+ "extends": "none",
756
+ "name": "forms",
757
+ "type": "registry:style",
758
+ "title": "dowel forms",
759
+ "description": "What a form is made of: the fields, the two ways of choosing from a list, and the button that submits it.",
760
+ "registryDependencies": [
761
+ "https://lacodda.github.io/dowel/r/button.json",
762
+ "https://lacodda.github.io/dowel/r/chip.json",
763
+ "https://lacodda.github.io/dowel/r/combobox.json",
764
+ "https://lacodda.github.io/dowel/r/input.json",
765
+ "https://lacodda.github.io/dowel/r/select.json",
766
+ "https://lacodda.github.io/dowel/r/textarea.json"
767
+ ],
768
+ "docs": "Field, Checkbox, RadioGroup and Switch join this set in v0.16."
769
+ },
770
+ {
771
+ "extends": "none",
772
+ "name": "feedback",
773
+ "type": "registry:style",
774
+ "title": "dowel feedback",
775
+ "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.",
776
+ "registryDependencies": [
777
+ "https://lacodda.github.io/dowel/r/alert.json",
778
+ "https://lacodda.github.io/dowel/r/banner.json",
779
+ "https://lacodda.github.io/dowel/r/confirm-dialog.json",
780
+ "https://lacodda.github.io/dowel/r/toast.json"
781
+ ],
782
+ "docs": "Which of the four to reach for is the harder question: see https://lacodda.github.io/dowel/guides/overlays/"
783
+ },
784
+ {
785
+ "name": "agents",
786
+ "type": "registry:file",
787
+ "title": "Agent briefing",
788
+ "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.",
789
+ "files": [
790
+ {
791
+ "path": "files/AGENTS.md",
792
+ "target": "~/AGENTS.md",
793
+ "type": "registry:file",
794
+ "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"
795
+ }
796
+ ],
797
+ "docs": "Replace `<product>` in the accent import with your own, and add whatever else your project expects of an agent."
798
+ }
799
+ ]
800
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dowel-ui",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "The lacodda line design system: theme tokens and React primitives, distributed as a shadcn-compatible registry.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,11 @@
40
40
  },
41
41
  "./theme.css": "./dist/theme.css",
42
42
  "./tokens.json": "./dist/tokens.json",
43
+ "./registry.json": "./dist/registry.json",
43
44
  "./accents/*.css": "./dist/accents/*.css"
44
45
  },
45
46
  "scripts": {
46
- "build": "tsc && node ../../tools/copy-theme.mjs && node ../../tools/build-tokens-json.mjs && node ../../tools/build-accents.mjs && node ../../tools/build-registry.mjs",
47
+ "build": "tsc && node ../../tools/copy-theme.mjs && node ../../tools/build-tokens-json.mjs && node ../../tools/build-accents.mjs && node ../../tools/build-registry.mjs && node ../../tools/build-schema.mjs && node ../../tools/build-llms.mjs",
47
48
  "typecheck": "tsc --noEmit",
48
49
  "prepack": "node ../../tools/copy-readme.mjs"
49
50
  },