dowel-ui 0.26.0 → 0.27.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.
@@ -14,7 +14,7 @@
14
14
  "path": "dowel/theme.css",
15
15
  "target": "~/dowel/theme.css",
16
16
  "type": "registry:file",
17
- "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. `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 /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
17
+ "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
18
18
  }
19
19
  ],
20
20
  "docs": "Import the theme, then your product accent:\n\n @import './dowel/theme.css';\n @import './dowel/accents/kilna.css';\n\nOutside the line, set the colour directly instead:\n\n :root { --accent-base: #2f7d6b; }"
@@ -309,7 +309,7 @@
309
309
  "dependencies": [
310
310
  "@base-ui/react",
311
311
  "class-variance-authority",
312
- "dowel-ui@^0.26.0"
312
+ "dowel-ui@^0.27.0"
313
313
  ],
314
314
  "registryDependencies": [],
315
315
  "files": [
@@ -328,7 +328,7 @@
328
328
  "description": "The shape everyone recognises: weeks as columns, weekdays as rows, time running left to right, and a value carried by how dark a square is. Two products of the line asked for it by name before it existed.",
329
329
  "dependencies": [
330
330
  "class-variance-authority",
331
- "dowel-ui@^0.26.0"
331
+ "dowel-ui@^0.27.0"
332
332
  ],
333
333
  "registryDependencies": [
334
334
  "https://lacodda.github.io/dowel/r/activity-weeks.json"
@@ -348,7 +348,7 @@
348
348
  "title": "Activity-legend",
349
349
  "description": "Separate from the grid because a caller showing three grids on one screen wants one legend, and because the words in it are the product's.",
350
350
  "dependencies": [
351
- "dowel-ui@^0.26.0"
351
+ "dowel-ui@^0.27.0"
352
352
  ],
353
353
  "registryDependencies": [
354
354
  "https://lacodda.github.io/dowel/r/activity-heatmap.json",
@@ -386,7 +386,7 @@
386
386
  "description": "A message that stays on the screen, in the flow of the page, about the thing next to it: this field could not be saved, this profile has no axes yet, this export is out of date.",
387
387
  "dependencies": [
388
388
  "class-variance-authority",
389
- "dowel-ui@^0.26.0"
389
+ "dowel-ui@^0.27.0"
390
390
  ],
391
391
  "registryDependencies": [],
392
392
  "files": [
@@ -398,6 +398,25 @@
398
398
  }
399
399
  ]
400
400
  },
401
+ {
402
+ "name": "avatar",
403
+ "type": "registry:ui",
404
+ "title": "Avatar",
405
+ "description": "A person, in the space of a word. Every screen that lists people needs one, and the three things that go wrong with it are always the same:\n * - **The picture fails to load** and a broken-image glyph appears where a face was. The fallback is not a nicety; it is the state this component spends most of its life in, because half the people in any list have no picture at all.",
406
+ "dependencies": [
407
+ "class-variance-authority",
408
+ "dowel-ui@^0.27.0"
409
+ ],
410
+ "registryDependencies": [],
411
+ "files": [
412
+ {
413
+ "path": "ui/avatar.tsx",
414
+ "target": "@ui/avatar.tsx",
415
+ "type": "registry:ui",
416
+ "content": "import { useState, type HTMLAttributes, type ImgHTMLAttributes, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Avatar and AvatarGroup.\n *\n * A person, in the space of a word. Every screen that lists people needs one,\n * and the three things that go wrong with it are always the same:\n *\n * - **The picture fails to load** and a broken-image glyph appears where a\n * face was. The fallback is not a nicety; it is the state this component\n * spends most of its life in, because half the people in any list have no\n * picture at all.\n * - **The initials are cut from the wrong end.** Splitting a name on spaces\n * and taking the first letter of each part is the version everyone writes,\n * and it turns a one-word name into one letter, a hyphenated surname into\n * three, and a name in a script without spaces into whatever fell out.\n * - **It is announced twice.** A picture with the person's name as `alt`, next\n * to the person's name as text, reads the name twice to a screen reader.\n *\n * So the picture is `alt=\"\"` and the name lives in the container's label; the\n * initials are computed by a rule that says what it does; and the fallback is\n * the default rather than the exception.\n *\n * What it does not do is choose a colour from the name. A hash of a string\n * into a hue looks charming on a design page and produces, in a real list,\n * colours that collide with the status vocabulary - a face is not `--bad` -\n * and that differ between products for the same person. The ground is the\n * neutral `--soft`, always, and identity comes from the picture or the\n * letters.\n */\n\nexport const avatarVariants = cva(\n 'relative inline-flex shrink-0 items-center justify-center overflow-hidden bg-soft font-medium text-dim select-none',\n {\n variants: {\n size: {\n xs: 'size-5 text-2xs',\n sm: 'size-6 text-2xs',\n md: 'size-8 text-xs',\n lg: 'size-10 text-sm',\n xl: 'size-14 text-lg',\n },\n shape: {\n circle: 'rounded-full',\n /* For a thing rather than a person - a project, a repository. Square\n * is how every other tool says \"this is not a face\", and borrowing\n * that saves the product a caption. */\n square: 'rounded-md',\n },\n },\n defaultVariants: { size: 'md', shape: 'circle' },\n },\n)\n\n/**\n * The letters shown when there is no picture.\n *\n * Exported because it is the part a product is most likely to want on its own -\n * in a mention, in a chart legend - and because a rule you can call is a rule\n * you can test.\n *\n * What it does, and why:\n *\n * - Words are what remain after whitespace; punctuation between them (a\n * hyphenated surname, an initial with a dot) does not make a new word, so\n * `Anne-Marie Dubois` is `AD` and not `AMD`.\n * - Two letters when there are two or more words, taken from the first and the\n * *last* - the last word is the family name in most of the line's languages,\n * and a middle name has no business on the tile.\n * - Two letters from a single word, because one letter on its own is a smaller\n * difference between two people than the tile is wide.\n * - Case is raised at the end, once, so a name in a script without case comes\n * through unchanged rather than mangled.\n *\n * Uses the string iterator rather than `charAt`: a name may begin with an\n * emoji or a character outside the basic plane, and taking half of a surrogate\n * pair puts a replacement glyph on the tile.\n */\nexport function initialsOf(name: string, count = 2): string {\n const words = name.trim().split(/\\s+/).filter(Boolean)\n const first = words[0]\n const last = words[words.length - 1]\n if (first === undefined || last === undefined) return ''\n\n const firstOf = (word: string) => [...word][0] ?? ''\n\n const letters =\n words.length === 1 ? [...first].slice(0, count) : [firstOf(first), firstOf(last)].slice(0, count)\n\n return letters.join('').toLocaleUpperCase()\n}\n\nexport interface AvatarProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,\n VariantProps<typeof avatarVariants> {\n /** Who or what this stands for. Always required: it is the name a reader who\n * does not see the picture gets, and the source of the initials. */\n name: string\n /** The picture. Absent, or failing to load, falls back to the initials. */\n src?: string\n /** Anything to draw instead of the initials when there is no picture - an\n * icon for a thing that is not a person. */\n fallback?: ReactNode\n /** Passed to the `<img>`, for `loading` or `referrerPolicy`. */\n imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'onError'>\n}\n\nexport function Avatar({\n name,\n src,\n fallback,\n size,\n shape,\n imgProps,\n className,\n ...props\n}: AvatarProps) {\n // A picture that 404s is the common case, not the odd one: profile hosts\n // expire links. Without this the tile shows a browser's broken-image glyph,\n // which is worse than the initials it was covering.\n const [failed, setFailed] = useState(false)\n const showImage = Boolean(src) && !failed\n\n return (\n <span\n className={cn(avatarVariants({ size, shape }), className)}\n // The tile as a whole is the picture of the person. The `<img>` inside\n // carries `alt=\"\"` so the name is announced once, from here.\n role=\"img\"\n aria-label={name}\n {...props}\n >\n {showImage ? (\n <img\n {...imgProps}\n src={src}\n alt=\"\"\n onError={() => setFailed(true)}\n className={cn('size-full object-cover', imgProps?.className)}\n />\n ) : (\n (fallback ?? initialsOf(name))\n )}\n </span>\n )\n}\n\nexport interface AvatarGroupProps extends HTMLAttributes<HTMLDivElement> {\n /** How many tiles to show before the rest become a count. */\n max?: number\n /** The people. Given as data rather than as children, because the group has\n * to know how many there are to say how many are hidden. */\n people: Array<{ name: string; src?: string }>\n size?: AvatarProps['size']\n /** What the whole row is called, for a screen reader - \"Assignees\", in the\n * product's word. */\n label?: string\n}\n\n/**\n * A row of faces, overlapping, with a count for the rest.\n *\n * The overlap is what makes this a group rather than a list: it says \"these\n * belong to one thing\" in less width than the names would take. The order is\n * the caller's, and the first tile is on top - the eye reads left to right and\n * the leftmost should be the whole one.\n *\n * The count tile is not an avatar. It has no name behind it, so it is not\n * `role=\"img\"` and gets no label of its own; the group's label covers the row,\n * and `+3` is read as the text it is.\n */\nexport function AvatarGroup({\n people,\n max = 4,\n size = 'sm',\n label,\n className,\n ...props\n}: AvatarGroupProps) {\n const shown = people.slice(0, Math.max(0, max))\n const hidden = people.length - shown.length\n\n return (\n <div\n className={cn('flex items-center', className)}\n role={label ? 'group' : undefined}\n aria-label={label}\n {...props}\n >\n {shown.map((person, index) => (\n <Avatar\n key={`${person.name}-${index}`}\n name={person.name}\n src={person.src}\n size={size}\n // The ring is the page's own background, so each tile cuts a clean\n // edge out of the one behind it. `-ml` on all but the first, because\n // the first has nothing to overlap.\n //\n // The stack runs the other way from the DOM: later siblings paint on\n // top by default, which would put the last face over the first. The\n // eye reads left to right and expects the leftmost tile whole, so\n // the order is reversed explicitly.\n style={{ zIndex: shown.length - index }}\n className={cn('relative ring-2 ring-bg', index > 0 && '-ml-2')}\n />\n ))}\n {hidden > 0 && (\n <span\n className={cn(avatarVariants({ size, shape: 'circle' }), 'relative -ml-2 ring-2 ring-bg')}\n // Behind every face, at the end of the row.\n style={{ zIndex: 0 }}\n >\n +{hidden}\n </span>\n )}\n </div>\n )\n}\n"
417
+ }
418
+ ]
419
+ },
401
420
  {
402
421
  "name": "badge",
403
422
  "type": "registry:ui",
@@ -405,7 +424,7 @@
405
424
  "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.",
406
425
  "dependencies": [
407
426
  "class-variance-authority",
408
- "dowel-ui@^0.26.0"
427
+ "dowel-ui@^0.27.0"
409
428
  ],
410
429
  "registryDependencies": [],
411
430
  "files": [
@@ -424,7 +443,7 @@
424
443
  "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.",
425
444
  "dependencies": [
426
445
  "class-variance-authority",
427
- "dowel-ui@^0.26.0"
446
+ "dowel-ui@^0.27.0"
428
447
  ],
429
448
  "registryDependencies": [],
430
449
  "files": [
@@ -443,7 +462,7 @@
443
462
  "description": "Bars rather than a line, and the distinction is the data's not the drawing's: a line says the value exists between the points, a column says each period is its own sum. Hours worked in a week is a sum - there is no \"Wednesday afternoon\" reading between two weeks - so it is a column.",
444
463
  "dependencies": [
445
464
  "class-variance-authority",
446
- "dowel-ui@^0.26.0"
465
+ "dowel-ui@^0.27.0"
447
466
  ],
448
467
  "registryDependencies": [],
449
468
  "files": [
@@ -463,7 +482,7 @@
463
482
  "dependencies": [
464
483
  "@base-ui/react",
465
484
  "class-variance-authority",
466
- "dowel-ui@^0.26.0"
485
+ "dowel-ui@^0.27.0"
467
486
  ],
468
487
  "registryDependencies": [],
469
488
  "files": [
@@ -497,7 +516,7 @@
497
516
  "title": "Calendar",
498
517
  "description": "The sums live next door in `calendar-math`, which has no React in it; this is the grid that draws them and the keyboard that moves around it.",
499
518
  "dependencies": [
500
- "dowel-ui@^0.26.0"
519
+ "dowel-ui@^0.27.0"
501
520
  ],
502
521
  "registryDependencies": [
503
522
  "https://lacodda.github.io/dowel/r/calendar-math.json"
@@ -518,7 +537,7 @@
518
537
  "description": "The interesting part is the words. A checkbox on its own is a nine-pixel target that says nothing; wired to a label it is the whole row, and the row is what a finger and a pointer both aim at. So the label is part of the component rather than something a caller remembers to add - the commonest bug in a hand-rolled checkbox is a `<label>` that is next to the input instead of tied to it, which looks identical and does nothing.",
519
538
  "dependencies": [
520
539
  "@base-ui/react",
521
- "dowel-ui@^0.26.0"
540
+ "dowel-ui@^0.27.0"
522
541
  ],
523
542
  "registryDependencies": [],
524
543
  "files": [
@@ -537,7 +556,7 @@
537
556
  "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.",
538
557
  "dependencies": [
539
558
  "class-variance-authority",
540
- "dowel-ui@^0.26.0"
559
+ "dowel-ui@^0.27.0"
541
560
  ],
542
561
  "registryDependencies": [],
543
562
  "files": [
@@ -556,7 +575,7 @@
556
575
  "description": "The frame around a piece of code is the same everywhere and is written again in every product: the scroll that must not wrap, the gutter of line numbers that must not be selectable, the copy button, the caption saying which file this is, and the marking of the lines the reader was sent here to look at.",
557
576
  "dependencies": [
558
577
  "class-variance-authority",
559
- "dowel-ui@^0.26.0"
578
+ "dowel-ui@^0.27.0"
560
579
  ],
561
580
  "registryDependencies": [
562
581
  "https://lacodda.github.io/dowel/r/copy-button.json"
@@ -576,7 +595,7 @@
576
595
  "title": "Color-field",
577
596
  "description": "Picking a colour for something the product stores: a tag, a project, a calendar. Note what that is *not* - it is not choosing the appearance of the interface. The theme decides that, from one accent, and a field that let a reader repaint the chrome would undo the argument the whole system rests on.",
578
597
  "dependencies": [
579
- "dowel-ui@^0.26.0"
598
+ "dowel-ui@^0.27.0"
580
599
  ],
581
600
  "registryDependencies": [
582
601
  "https://lacodda.github.io/dowel/r/input.json"
@@ -596,7 +615,7 @@
596
615
  "title": "Column-resize-handle",
597
616
  "description": "The handle, and the hook that keeps the widths it produces. Pointer events rather than HTML5 drag-and-drop: a desktop shell that takes file drops for itself never lets a `dragstart` reach the page, so the native API is a handle that does nothing there; pointer capture on the handle also keeps the drag alive when the pointer runs ahead of the cell, which at any speed above a crawl it does.",
598
617
  "dependencies": [
599
- "dowel-ui@^0.26.0"
618
+ "dowel-ui@^0.27.0"
600
619
  ],
601
620
  "registryDependencies": [],
602
621
  "files": [
@@ -616,7 +635,7 @@
616
635
  "dependencies": [
617
636
  "@base-ui/react",
618
637
  "class-variance-authority",
619
- "dowel-ui@^0.26.0"
638
+ "dowel-ui@^0.27.0"
620
639
  ],
621
640
  "registryDependencies": [
622
641
  "https://lacodda.github.io/dowel/r/input.json",
@@ -639,7 +658,7 @@
639
658
  "dependencies": [
640
659
  "@base-ui/react",
641
660
  "class-variance-authority",
642
- "dowel-ui@^0.26.0"
661
+ "dowel-ui@^0.27.0"
643
662
  ],
644
663
  "registryDependencies": [
645
664
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -662,7 +681,7 @@
662
681
  "dependencies": [
663
682
  "@base-ui/react",
664
683
  "class-variance-authority",
665
- "dowel-ui@^0.26.0"
684
+ "dowel-ui@^0.27.0"
666
685
  ],
667
686
  "registryDependencies": [],
668
687
  "files": [
@@ -681,7 +700,7 @@
681
700
  "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>`.",
682
701
  "dependencies": [
683
702
  "@base-ui/react",
684
- "dowel-ui@^0.26.0"
703
+ "dowel-ui@^0.27.0"
685
704
  ],
686
705
  "registryDependencies": [
687
706
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -701,7 +720,7 @@
701
720
  "title": "Copy-button",
702
721
  "description": "Whatever a product shows in a panel - code, a payload, a log, one side of a comparison - somebody eventually wants to take it away, and the button that lets them is written again every time with the same three things missed.",
703
722
  "dependencies": [
704
- "dowel-ui@^0.26.0"
723
+ "dowel-ui@^0.27.0"
705
724
  ],
706
725
  "registryDependencies": [],
707
726
  "files": [
@@ -719,7 +738,7 @@
719
738
  "title": "Copyable",
720
739
  "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.",
721
740
  "dependencies": [
722
- "dowel-ui@^0.26.0"
741
+ "dowel-ui@^0.27.0"
723
742
  ],
724
743
  "registryDependencies": [],
725
744
  "files": [
@@ -737,7 +756,7 @@
737
756
  "title": "Date-picker",
738
757
  "description": "The trigger is a button rather than a text input, and that is the decision worth stating. A typable date field has to answer \"what does `03/04/26` mean\" in a locale it cannot be sure of, and it answers wrong for half the world; a button showing the date spelled out has no such question. Where typing genuinely matters - a birth date, forty years back - the calendar is the wrong control anyway and a product should reach for a plain field.",
739
758
  "dependencies": [
740
- "dowel-ui@^0.26.0"
759
+ "dowel-ui@^0.27.0"
741
760
  ],
742
761
  "registryDependencies": [
743
762
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -760,7 +779,7 @@
760
779
  "title": "Date-range-picker",
761
780
  "description": "The interesting part is the state between them. After the first click there is a start and no end, and that is not an incomplete range to be hidden or a range of one day - it is the normal middle of the interaction, and the calendar has to show it: the first day marked, the days under the pointer shading as the reader moves, the popup staying open. Products that skip it end up with a picker that seems to do nothing until the second click.",
762
781
  "dependencies": [
763
- "dowel-ui@^0.26.0"
782
+ "dowel-ui@^0.27.0"
764
783
  ],
765
784
  "registryDependencies": [
766
785
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -785,7 +804,7 @@
785
804
  "dependencies": [
786
805
  "@base-ui/react",
787
806
  "class-variance-authority",
788
- "dowel-ui@^0.26.0"
807
+ "dowel-ui@^0.27.0"
789
808
  ],
790
809
  "registryDependencies": [],
791
810
  "files": [
@@ -820,7 +839,7 @@
820
839
  "description": "The question this answers is \"how did this read before, and how does it read now\" - a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is no staging, no comment, nothing to accept. It is for looking.",
821
840
  "dependencies": [
822
841
  "class-variance-authority",
823
- "dowel-ui@^0.26.0"
842
+ "dowel-ui@^0.27.0"
824
843
  ],
825
844
  "registryDependencies": [
826
845
  "https://lacodda.github.io/dowel/r/copy-button.json",
@@ -843,7 +862,7 @@
843
862
  "dependencies": [
844
863
  "@base-ui/react",
845
864
  "class-variance-authority",
846
- "dowel-ui@^0.26.0"
865
+ "dowel-ui@^0.27.0"
847
866
  ],
848
867
  "registryDependencies": [],
849
868
  "files": [
@@ -861,7 +880,7 @@
861
880
  "title": "Duration-field",
862
881
  "description": "The alternative is what products keep building: two number boxes labelled \"hours\" and \"minutes\", which means two tab stops, two validations, and a reader who has to divide 90 minutes in their head before typing. Here they write `1h 30m`, or `90m`, or `1.5h`, and it means the same thing.",
863
882
  "dependencies": [
864
- "dowel-ui@^0.26.0"
883
+ "dowel-ui@^0.27.0"
865
884
  ],
866
885
  "registryDependencies": [
867
886
  "https://lacodda.github.io/dowel/r/input.json"
@@ -882,7 +901,7 @@
882
901
  "description": "Three kinds of nothing, and a product that draws the same panel for all three is telling the reader the wrong thing twice:\n * **empty** - there is nothing here yet, and that is normal. The panel says what would be here and offers the one action that makes it appear.",
883
902
  "dependencies": [
884
903
  "class-variance-authority",
885
- "dowel-ui@^0.26.0"
904
+ "dowel-ui@^0.27.0"
886
905
  ],
887
906
  "registryDependencies": [],
888
907
  "files": [
@@ -920,7 +939,7 @@
920
939
  "description": "Every form is the same four parts repeated: a name for the control, the control, sometimes a hint, and sometimes an error. Written by hand each time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no screen reader mentions, the error appears in red and is announced by nothing at all. This is that arrangement, once.",
921
940
  "dependencies": [
922
941
  "@base-ui/react",
923
- "dowel-ui@^0.26.0"
942
+ "dowel-ui@^0.27.0"
924
943
  ],
925
944
  "registryDependencies": [],
926
945
  "files": [
@@ -938,7 +957,7 @@
938
957
  "title": "File-drop",
939
958
  "description": "A place to put files: drag them onto it, or press it and pick them. It takes files and hands them over - it does not upload them. Where they go, with which credentials, retried how - that is the product's transport, and a primitive that owned it would be wrong for every product whose upload does not look like the one it guessed.",
940
959
  "dependencies": [
941
- "dowel-ui@^0.26.0"
960
+ "dowel-ui@^0.27.0"
942
961
  ],
943
962
  "registryDependencies": [],
944
963
  "files": [
@@ -956,7 +975,7 @@
956
975
  "title": "Filter-popover",
957
976
  "description": "A text box, a handful of checkboxes - with one way to clear it. The shell only: what goes in the panel is the caller's, since a stage is ticked and a title is typed and the popover has no opinion.",
958
977
  "dependencies": [
959
- "dowel-ui@^0.26.0"
978
+ "dowel-ui@^0.27.0"
960
979
  ],
961
980
  "registryDependencies": [
962
981
  "https://lacodda.github.io/dowel/r/button.json",
@@ -977,7 +996,7 @@
977
996
  "title": "Input",
978
997
  "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.",
979
998
  "dependencies": [
980
- "dowel-ui@^0.26.0"
999
+ "dowel-ui@^0.27.0"
981
1000
  ],
982
1001
  "registryDependencies": [],
983
1002
  "files": [
@@ -1011,7 +1030,7 @@
1011
1030
  "title": "Json-viewer",
1012
1031
  "description": "What a product reaches for when it has to show a response, a settings file, a webhook payload - data the reader needs to understand, not edit. The alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.",
1013
1032
  "dependencies": [
1014
- "dowel-ui@^0.26.0"
1033
+ "dowel-ui@^0.27.0"
1015
1034
  ],
1016
1035
  "registryDependencies": [
1017
1036
  "https://lacodda.github.io/dowel/r/json-rows.json"
@@ -1031,7 +1050,7 @@
1031
1050
  "title": "Kbd",
1032
1051
  "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.",
1033
1052
  "dependencies": [
1034
- "dowel-ui@^0.26.0"
1053
+ "dowel-ui@^0.27.0"
1035
1054
  ],
1036
1055
  "registryDependencies": [],
1037
1056
  "files": [
@@ -1050,7 +1069,7 @@
1050
1069
  "description": "The shape every product builds out of two `<div>`s in a flex row, and the reason it is worth having once: it is a `<dl>`, and the pairing is what a screen reader announces. Two divs read as four unrelated pieces of text - \"Created\", \"2 hours ago\", \"Owner\", \"Ines\" - and nothing says which value belongs to which name. The right element says it for free.",
1051
1070
  "dependencies": [
1052
1071
  "class-variance-authority",
1053
- "dowel-ui@^0.26.0"
1072
+ "dowel-ui@^0.27.0"
1054
1073
  ],
1055
1074
  "registryDependencies": [],
1056
1075
  "files": [
@@ -1069,7 +1088,7 @@
1069
1088
  "description": "The distinction against its neighbours is the data's, not the drawing's. A column says each period is its own sum - hours worked in a week, and there is no Wednesday-afternoon figure between two weeks. A line says the value existed the whole time and was sampled: an account balance, a price, a temperature. Drawing a sum as a line claims readings nobody took; drawing a level as columns throws away the thing being watched.",
1070
1089
  "dependencies": [
1071
1090
  "class-variance-authority",
1072
- "dowel-ui@^0.26.0"
1091
+ "dowel-ui@^0.27.0"
1073
1092
  ],
1074
1093
  "registryDependencies": [
1075
1094
  "https://lacodda.github.io/dowel/r/line-scale.json"
@@ -1105,7 +1124,7 @@
1105
1124
  "title": "Marked-text",
1106
1125
  "description": "A textarea cannot colour a word. The way round it is older than React: draw the same text twice, once as marked-up HTML underneath and once as the textarea on top with its own text transparent, so the caret and the selection are the browser's and the colours are ours. The two have to agree on every metric - font, size, line height, padding, wrapping - or the marks slide off the words they mark. So both take ONE class list, given by the caller, and the textarea adds only what makes it invisible.",
1107
1126
  "dependencies": [
1108
- "dowel-ui@^0.26.0"
1127
+ "dowel-ui@^0.27.0"
1109
1128
  ],
1110
1129
  "registryDependencies": [],
1111
1130
  "files": [
@@ -1125,7 +1144,7 @@
1125
1144
  "dependencies": [
1126
1145
  "@base-ui/react",
1127
1146
  "class-variance-authority",
1128
- "dowel-ui@^0.26.0"
1147
+ "dowel-ui@^0.27.0"
1129
1148
  ],
1130
1149
  "registryDependencies": [],
1131
1150
  "files": [
@@ -1133,7 +1152,7 @@
1133
1152
  "path": "ui/menu.tsx",
1134
1153
  "target": "@ui/menu.tsx",
1135
1154
  "type": "registry:ui",
1136
- "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"
1155
+ "content": "import { Menu as Base } from '@base-ui/react/menu'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Menu.\n *\n * A list of actions that opens from a button - the row menu, the overflow\n * menu, the one behind the three dots. The products all wrote this by hand,\n * and all of them wrote the same three hundred lines: a click-outside\n * listener, an Escape handler, and a `stopPropagation` on every item so that\n * choosing an action does not also open the row underneath.\n *\n * None of that is what makes a menu hard. What makes it hard is the keyboard:\n * arrows that wrap, Home and End, type-ahead that finds an item by its first\n * letters, a submenu that opens on the right key and closes when the pointer\n * leaves diagonally. Base UI has all of it.\n *\n * The items are exposed rather than taken as an array of `{ label, onSelect }`.\n * An array is enough until the first separator, the first checkbox item and\n * the first submenu - and each of those arrives as another field on the object\n * rather than as the JSX it obviously is.\n */\n\nexport const menuPopupVariants = cva(\n [\n 'min-w-40 rounded-md border border-line bg-raise p-1 text-text shadow-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'min-w-32',\n md: 'min-w-40',\n lg: 'min-w-56',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One row of the menu. `danger` draws the destructive one apart from the\n * rest - in the colour of something that cannot be undone. */\nexport const menuItemVariants = cva(\n [\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n ],\n {\n variants: {\n tone: {\n default: 'text-dim data-[highlighted]:text-text',\n danger: 'text-bad data-[highlighted]:bg-bad-soft data-[highlighted]:text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n },\n)\n\n/** The root. Uncontrolled by default; `open` and `onOpenChange` make it\n * controlled. */\nexport const Menu = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const MenuTrigger = Base.Trigger\n\n/** A labelled group of items, for a menu long enough to need headings. */\nexport const MenuGroup = Base.Group\n\n/** A submenu, opened from a `MenuSubTrigger` inside the parent. */\nexport const MenuSub = Base.SubmenuRoot\n\nexport interface MenuPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof menuPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the menu\n * from being clipped by a row with `overflow: hidden` - which is where most\n * hand-written ones go to die. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned against the trigger. */\nexport function MenuPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: MenuPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(menuPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface MenuItemProps extends Base.Item.Props, VariantProps<typeof menuItemVariants> {}\n\n/** An action. Closing the menu afterwards is Base UI's default, which is\n * almost always right - `closeOnClick={false}` is for the one that is not. */\nexport function MenuItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.Item className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that opens a submenu. Drawn like any other, because it is one. */\nexport function MenuSubTrigger({ tone, className, ...props }: MenuItemProps) {\n return <Base.SubmenuTrigger className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** An item that carries a tick. The state is the caller's - a menu does not\n * remember anything. */\nexport function MenuCheckboxItem({ tone, className, ...props }: MenuItemProps) {\n return <Base.CheckboxItem className={cn(menuItemVariants({ tone }), className)} {...props} />\n}\n\n/** The tick itself, drawn only when the item is checked. */\nexport const MenuCheckboxIndicator = Base.CheckboxItemIndicator\n\n/** A line between groups of items. Decorative, and marked as such: a screen\n * reader announcing \"separator\" between every pair of actions is noise. */\nexport function MenuSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function MenuGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n"
1137
1156
  }
1138
1157
  ]
1139
1158
  },
@@ -1143,7 +1162,7 @@
1143
1162
  "title": "Notification-bell",
1144
1163
  "description": "A bell that is always lit is a bell nobody reads, so the count is the product's decision and this draws it: nothing at zero, the number past that, `9+` past nine. Pressing it does not leave the screen - the last few entries open under it and the whole history is one more click, which is the shape every product converged on once the first one tried a page.",
1145
1164
  "dependencies": [
1146
- "dowel-ui@^0.26.0"
1165
+ "dowel-ui@^0.27.0"
1147
1166
  ],
1148
1167
  "registryDependencies": [
1149
1168
  "https://lacodda.github.io/dowel/r/button.json",
@@ -1165,7 +1184,7 @@
1165
1184
  "description": "A number typed into a text input is a string that happens to look like a number, and every product then writes the same four fixes: strip the letters, clamp to a range, round to a step, and decide what an empty box means. This is those four, once, plus the stepper - because a value with a small range is faster nudged than typed.",
1166
1185
  "dependencies": [
1167
1186
  "@base-ui/react",
1168
- "dowel-ui@^0.26.0"
1187
+ "dowel-ui@^0.27.0"
1169
1188
  ],
1170
1189
  "registryDependencies": [
1171
1190
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1185,7 +1204,7 @@
1185
1204
  "title": "Number-format",
1186
1205
  "description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
1187
1206
  "dependencies": [
1188
- "dowel-ui@^0.26.0"
1207
+ "dowel-ui@^0.27.0"
1189
1208
  ],
1190
1209
  "registryDependencies": [],
1191
1210
  "files": [
@@ -1203,7 +1222,7 @@
1203
1222
  "title": "Page-size",
1204
1223
  "description": "Its own file rather than a part of `Pagination`, because the two are needed apart often enough: a list that scrolls for ever wants \"how many to load at a time\" and no page buttons, and a table with a fixed page size wants the buttons and no choice. Together they were also over the size gate, which asked the right question.",
1205
1224
  "dependencies": [
1206
- "dowel-ui@^0.26.0"
1225
+ "dowel-ui@^0.27.0"
1207
1226
  ],
1208
1227
  "registryDependencies": [
1209
1228
  "https://lacodda.github.io/dowel/r/select.json"
@@ -1223,7 +1242,7 @@
1223
1242
  "title": "Pagination",
1224
1243
  "description": "The arithmetic is exported separately from the component for the same reason `table-sort` is a file of its own: a product that pages on the server needs the page numbers and not the buttons, and computing them a second time in a different place is how the two disagree about where the last page ends.",
1225
1244
  "dependencies": [
1226
- "dowel-ui@^0.26.0"
1245
+ "dowel-ui@^0.27.0"
1227
1246
  ],
1228
1247
  "registryDependencies": [
1229
1248
  "https://lacodda.github.io/dowel/r/button.json"
@@ -1244,7 +1263,7 @@
1244
1263
  "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.",
1245
1264
  "dependencies": [
1246
1265
  "class-variance-authority",
1247
- "dowel-ui@^0.26.0"
1266
+ "dowel-ui@^0.27.0"
1248
1267
  ],
1249
1268
  "registryDependencies": [],
1250
1269
  "files": [
@@ -1262,7 +1281,7 @@
1262
1281
  "title": "Password-field",
1263
1282
  "description": "The reveal is the whole component, and it is not a convenience. A masked field is the only one in a form where a typo cannot be seen, so people either paste (fine) or type slowly and get it wrong anyway; the toggle is what turns an unverifiable field into a checkable one, and it is why long passphrases became usable at all.",
1264
1283
  "dependencies": [
1265
- "dowel-ui@^0.26.0"
1284
+ "dowel-ui@^0.27.0"
1266
1285
  ],
1267
1286
  "registryDependencies": [
1268
1287
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1284,7 +1303,7 @@
1284
1303
  "dependencies": [
1285
1304
  "@base-ui/react",
1286
1305
  "class-variance-authority",
1287
- "dowel-ui@^0.26.0"
1306
+ "dowel-ui@^0.27.0"
1288
1307
  ],
1289
1308
  "registryDependencies": [],
1290
1309
  "files": [
@@ -1292,7 +1311,7 @@
1292
1311
  "path": "ui/popover.tsx",
1293
1312
  "target": "@ui/popover.tsx",
1294
1313
  "type": "registry:ui",
1295
- "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"
1314
+ "content": "import { Popover as Base } from '@base-ui/react/popover'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Popover.\n *\n * A panel anchored to the thing that opened it, for content that belongs\n * beside a control rather than in the middle of the screen: a filter, a small\n * form, a menu of settings that is not a menu.\n *\n * Where Dialog is centred and covers the page, this one is placed, and the\n * placing is the part that is easy to get wrong. It goes through a Positioner\n * between the Portal and the Popup - Base UI measures the trigger, measures\n * the popup, and flips or shifts it when the preferred side does not fit. So\n * `side` and `align` are a preference, not an instruction, and a popover near\n * the bottom of the window will come out above its trigger. That is the\n * behaviour worth having; a popover that stays where it was told is a popover\n * half off the screen.\n *\n * `sideOffset` is a distance from the anchor and belongs to the Positioner,\n * not the Popup, so it is taken here and passed inward. The Arrow is optional\n * and sits inside the Popup, where Base UI rotates it to whichever side the\n * popup actually landed on.\n *\n * It is not modal. The page underneath stays live, and focus is not trapped -\n * which is right for a panel beside a control and wrong for a decision.\n */\n\nexport const popoverPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(16rem,calc(100vw-2rem))]',\n md: 'w-[min(20rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PopoverTrigger`. */\nexport const Popover = Base.Root\n\n/** What opens it, and what the popup is measured against. */\nexport const PopoverTrigger = Base.Trigger\n\n/** What closes it, for a button inside the panel. */\nexport const PopoverClose = Base.Close\n\nexport interface PopoverPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof popoverPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The panel. Portalled and positioned, so it is not clipped by an ancestor\n * with `overflow: hidden` - which is where most anchored popups go to die.\n *\n * `side` and `align` are passed straight through rather than defaulted here:\n * Base UI's own defaults are already bottom and centre, and writing them out\n * again would be one more place for the two to disagree. */\nexport function PopoverPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PopoverPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(popoverPopupVariants({ size }), className)} {...props}>\n {arrow ? <PopoverArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the popup landed on, which is why the rotation is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PopoverArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * popover with one is named for a screen reader without anyone arranging it. */\nexport function PopoverTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-sm font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function PopoverDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
1296
1315
  }
1297
1316
  ]
1298
1317
  },
@@ -1304,7 +1323,7 @@
1304
1323
  "dependencies": [
1305
1324
  "@base-ui/react",
1306
1325
  "class-variance-authority",
1307
- "dowel-ui@^0.26.0"
1326
+ "dowel-ui@^0.27.0"
1308
1327
  ],
1309
1328
  "registryDependencies": [],
1310
1329
  "files": [
@@ -1312,7 +1331,7 @@
1312
1331
  "path": "ui/preview-card.tsx",
1313
1332
  "target": "@ui/preview-card.tsx",
1314
1333
  "type": "registry:ui",
1315
- "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"
1334
+ "content": "import { PreviewCard as Base } from '@base-ui/react/preview-card'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * PreviewCard.\n *\n * The card that appears when a link is hovered: who the author is, what the\n * issue says, what is behind the URL. Rich content - an avatar, a few lines,\n * a figure or two - rather than the phrase a Tooltip holds.\n *\n * Positioned the same way a Popover is, through a Positioner between the\n * Portal and the Popup, so it flips and shifts to stay on screen. What differs\n * is how it opens: hovering the trigger, after a delay, and it stays open\n * while the pointer travels from the link to the card. That last part is the\n * whole trick - a card that vanishes when the pointer leaves the link cannot\n * be read, let alone clicked into.\n *\n * Same warning as Tooltip, and for the same reason. Base UI treats this as a\n * visual enhancement for sighted mouse and keyboard users: it is not reachable\n * on a touch screen and not announced by a screen reader. So NOTHING IN THE\n * CARD MAY BE THE ONLY PLACE IT APPEARS. Everything in it has to also be on\n * the page the link goes to - the card is a shortcut for people who can see\n * it, never the delivery mechanism for the information itself.\n *\n * If the content has to be reachable by everyone, this is the wrong component;\n * a Popover opened from a real button is the right one.\n */\n\nexport const previewCardPopupVariants = cva(\n [\n 'rounded-lg border border-line bg-raise p-4 text-sm text-text shadow-raise',\n 'focus-visible:outline-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.97] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.97] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the link.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(18rem,calc(100vw-2rem))]',\n md: 'w-[min(22rem,calc(100vw-2rem))]',\n lg: 'w-[min(28rem,calc(100vw-2rem))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `PreviewCardTrigger`. */\nexport const PreviewCard = Base.Root\n\n/** The link the card previews. Usually rendered as the anchor itself, so it\n * stays a real link: it navigates, it opens in a new tab, and a screen reader\n * announces it as one - which is what the card cannot do. Takes `delay`. */\nexport const PreviewCardTrigger = Base.Trigger\n\nexport interface PreviewCardPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof previewCardPopupVariants> {\n /** Preferred side of the link. Base UI flips it when it does not fit, and\n * defaults it to the bottom. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the link, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the link. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The card. Portalled and positioned, so it is not clipped by the paragraph\n * the link sits in. */\nexport function PreviewCardPopup({\n size,\n side,\n align,\n sideOffset = 8,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: PreviewCardPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-floating)]\"\n >\n <Base.Popup className={cn(previewCardPopupVariants({ size }), className)} {...props}>\n {arrow ? <PreviewCardArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the link. Base UI rotates it to whatever side\n * the card landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function PreviewCardArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-2 w-2 rotate-45 border border-line bg-raise',\n 'data-[side=bottom]:-top-1 data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-1 data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-1 data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-1 data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1316
1335
  }
1317
1336
  ]
1318
1337
  },
@@ -1324,7 +1343,7 @@
1324
1343
  "dependencies": [
1325
1344
  "@base-ui/react",
1326
1345
  "class-variance-authority",
1327
- "dowel-ui@^0.26.0"
1346
+ "dowel-ui@^0.27.0"
1328
1347
  ],
1329
1348
  "registryDependencies": [],
1330
1349
  "files": [
@@ -1363,7 +1382,7 @@
1363
1382
  "dependencies": [
1364
1383
  "@base-ui/react",
1365
1384
  "class-variance-authority",
1366
- "dowel-ui@^0.26.0"
1385
+ "dowel-ui@^0.27.0"
1367
1386
  ],
1368
1387
  "registryDependencies": [],
1369
1388
  "files": [
@@ -1381,7 +1400,7 @@
1381
1400
  "title": "Rating-scale",
1382
1401
  "description": "Generalised from kilna, where it is how a work is scored on each of its axes. The shape is a row of marks rather than stars: stars carry a meaning of their own - a review, a public verdict - and this is as often \"how hard was this\" or \"how finished is it\" as it is \"how good\".",
1383
1402
  "dependencies": [
1384
- "dowel-ui@^0.26.0"
1403
+ "dowel-ui@^0.27.0"
1385
1404
  ],
1386
1405
  "registryDependencies": [],
1387
1406
  "files": [
@@ -1399,7 +1418,7 @@
1399
1418
  "title": "Relative-time",
1400
1419
  "description": "The relative-time primitive.",
1401
1420
  "dependencies": [
1402
- "dowel-ui@^0.26.0"
1421
+ "dowel-ui@^0.27.0"
1403
1422
  ],
1404
1423
  "registryDependencies": [],
1405
1424
  "files": [
@@ -1417,7 +1436,7 @@
1417
1436
  "title": "Reorderable-list",
1418
1437
  "description": "The columns in a column picker, the stops of a dial, the roles of a profile. A hook and a grip rather than a list component: the rows are already something else's - a menu's items, a form's fields - and a component wrapping them would have to reproduce whatever that something else does.",
1419
1438
  "dependencies": [
1420
- "dowel-ui@^0.26.0"
1439
+ "dowel-ui@^0.27.0"
1421
1440
  ],
1422
1441
  "registryDependencies": [],
1423
1442
  "files": [
@@ -1435,7 +1454,7 @@
1435
1454
  "title": "Save-state",
1436
1455
  "description": "The quiet line beside a field that saves itself: \"saving…\", then a tick that fades. It exists because a form without a Save button has to say what it did anyway - otherwise the reader is left guessing whether their edit survived, and the usual answer to that guess is to press Ctrl+S at a page that has no such thing.",
1437
1456
  "dependencies": [
1438
- "dowel-ui@^0.26.0"
1457
+ "dowel-ui@^0.27.0"
1439
1458
  ],
1440
1459
  "registryDependencies": [
1441
1460
  "https://lacodda.github.io/dowel/r/spinner.json"
@@ -1455,7 +1474,7 @@
1455
1474
  "title": "Search-field",
1456
1475
  "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.",
1457
1476
  "dependencies": [
1458
- "dowel-ui@^0.26.0"
1477
+ "dowel-ui@^0.27.0"
1459
1478
  ],
1460
1479
  "registryDependencies": [
1461
1480
  "https://lacodda.github.io/dowel/r/input.json",
@@ -1478,7 +1497,7 @@
1478
1497
  "description": "A settings screen is the usual case: five or six sections, each its own address so it can be linked to and the back button walks between them, listed down the left with the current one tinted. Every product draws the same column, and every product draws the active row a little differently - which is exactly the drift a shared list exists to stop.",
1479
1498
  "dependencies": [
1480
1499
  "@base-ui/react",
1481
- "dowel-ui@^0.26.0"
1500
+ "dowel-ui@^0.27.0"
1482
1501
  ],
1483
1502
  "registryDependencies": [],
1484
1503
  "files": [
@@ -1498,7 +1517,7 @@
1498
1517
  "dependencies": [
1499
1518
  "@base-ui/react",
1500
1519
  "class-variance-authority",
1501
- "dowel-ui@^0.26.0"
1520
+ "dowel-ui@^0.27.0"
1502
1521
  ],
1503
1522
  "registryDependencies": [
1504
1523
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1508,7 +1527,7 @@
1508
1527
  "path": "ui/select.tsx",
1509
1528
  "target": "@ui/select.tsx",
1510
1529
  "type": "registry:ui",
1511
- "content": "import { Select as Base } from '@base-ui/react/select'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * Select.\n *\n * The component the oldest rule in the line is about. A native `<select>`\n * cannot be dressed: the browser draws its popup itself, in the operating\n * system's chrome, and no CSS reaches inside. One native dropdown on a screen\n * of the product's own controls reads as a foreign object, and on Windows it\n * reads as a foreign object from 1998.\n *\n * So this renders `<button role=\"combobox\">` and a portalled list of\n * `role=\"option\"` - zero native elements, which is asserted in the test,\n * because it is the entire reason the component exists.\n *\n * What that costs is everything the browser was doing for free: the keyboard,\n * type-ahead, the announcement of the selected value, the scroll into view,\n * and on a phone the whole native picker. Base UI does all of it, which is\n * the only reason this trade is worth making - a hand-rolled dropdown is how\n * a product ships a control that a screen reader cannot see.\n *\n * The trigger wears Input's `fieldClasses`, imported rather than copied. A\n * select and a text field sit next to each other in every form there has ever\n * been, and two class lists that started the same drift within a release.\n *\n * `multiple` is a prop on the Root: it changes what `value` means - an array\n * rather than a single value - so it belongs where the value lives and not on\n * the trigger.\n */\n\nexport const selectTriggerVariants = cva([fieldClasses, 'flex items-center justify-between gap-2'], {\n variants: {\n size: {\n sm: 'h-8 text-xs',\n md: 'h-9',\n lg: 'h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const selectPopupVariants = cva(\n [\n 'max-h-[min(24rem,var(--available-height))] overflow-y-auto',\n 'rounded-md border border-line bg-raise p-1 text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n // What is already chosen has to be visible in the list, and colour alone\n // will not do it: `text-text` on an item that is already `text-text` says\n // nothing. The tick below is the state; this is the emphasis that goes with\n // it, so the row reads as chosen at a glance and not only under the eye.\n 'data-[selected]:font-medium data-[selected]:text-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in.\n *\n * It truncates, and that matters most for `multiple`: nine chosen fruits are\n * one long string, and without this the trigger either grows into a paragraph\n * or spills its text past its own border. One line, an ellipsis, and the full\n * set is still in the list where the ticks are. */\nexport function SelectValue({ className, ...props }: Base.Value.Props) {\n return <Base.Value className={cn('min-w-0 flex-1 truncate text-left', className)} {...props} />\n}\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the list\n * from being clipped by a form with `overflow: hidden`. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option, with the tick that says it is the chosen one.\n *\n * The indicator is built in rather than left to the caller. The item already\n * reserves the room for it (`pr-7`), and a dropdown that does not show what is\n * currently selected is the commonest complaint about a styled select: it\n * opens, and the reader has to remember what they picked last time. Passing\n * `indicator={false}` turns it off for a list where the choice is obvious\n * some other way. */\nexport function SelectItem({\n indicator = true,\n className,\n children,\n ...props\n}: Base.Item.Props & { indicator?: boolean }) {\n return (\n <Base.Item className={cn(selectItemVariants(), className)} {...props}>\n {children}\n {indicator && (\n <Base.ItemIndicator className=\"absolute right-2 flex text-accent\">\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.ItemIndicator>\n )}\n </Base.Item>\n )\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
1530
+ "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-raise',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n // The popup matches the trigger's width by default, which is what a\n // dropdown should do; the sizes are a floor for a narrow one.\n sm: 'min-w-[max(8rem,var(--anchor-width))]',\n md: 'min-w-[max(10rem,var(--anchor-width))]',\n lg: 'min-w-[max(14rem,var(--anchor-width))]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** One option. */\nexport const selectItemVariants = cva([\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-7 text-sm',\n 'outline-none transition-colors',\n // Base UI marks the item under the pointer or the keyboard the same way,\n // so one rule covers both and they cannot disagree.\n 'data-[highlighted]:bg-soft data-[highlighted]:text-text',\n // What is already chosen has to be visible in the list, and colour alone\n // will not do it: `text-text` on an item that is already `text-text` says\n // nothing. The tick below is the state; this is the emphasis that goes with\n // it, so the row reads as chosen at a glance and not only under the eye.\n 'data-[selected]:font-medium data-[selected]:text-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n '[&_svg]:size-3.5 [&_svg]:shrink-0',\n])\n\n/** The root. `multiple` turns `value` into an array; otherwise controlled with\n * `value` and `onValueChange`, or left to manage itself. */\nexport const Select = Base.Root\n\n/** What the trigger shows: the selected item's label, and the `placeholder`\n * the product gives it until there is one.\n *\n * Two traps, both Base UI's and both quiet. Its `children` is a *function* of\n * the value, not a node - passing a node pins the trigger to that node\n * forever and the selection never appears, so the placeholder goes in\n * `placeholder`. And what it shows is the raw value, `plum` rather than\n * `Plum`, unless the root is given an `items` map to look the label up in.\n *\n * It truncates, and that matters most for `multiple`: nine chosen fruits are\n * one long string, and without this the trigger either grows into a paragraph\n * or spills its text past its own border. One line, an ellipsis, and the full\n * set is still in the list where the ticks are. */\nexport function SelectValue({ className, ...props }: Base.Value.Props) {\n return <Base.Value className={cn('min-w-0 flex-1 truncate text-left', className)} {...props} />\n}\n\n/** The chevron, or whatever the product puts there. Marked decorative by Base\n * UI, since the button is already named by its value. */\nexport const SelectIcon = Base.Icon\n\n/** A labelled group of options. */\nexport const SelectGroup = Base.Group\n\n/** The text of an option, which is what the trigger echoes when it is chosen. */\nexport const SelectItemText = Base.ItemText\n\n/** The tick, drawn only on the chosen option. */\nexport const SelectItemIndicator = Base.ItemIndicator\n\nexport interface SelectTriggerProps\n extends Base.Trigger.Props,\n VariantProps<typeof selectTriggerVariants> {}\n\n/** The control. A `<button role=\"combobox\">` - never a `<select>`. */\nexport function SelectTrigger({ size, className, ...props }: SelectTriggerProps) {\n return <Base.Trigger className={cn(selectTriggerVariants({ size }), className)} {...props} />\n}\n\nexport interface SelectPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof selectPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Where to portal to. Defaults to the document body, which keeps the list\n * from being clipped by a form with `overflow: hidden`. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the trigger.\n *\n * `alignItemWithTrigger` is off: Base UI's default lifts the popup so the\n * selected option sits over the button, which is the native macOS behaviour\n * and is disorienting in a web form - the list jumps to a different place\n * depending on what is already chosen. */\nexport function SelectPopup({\n size,\n side,\n align,\n sideOffset = 4,\n container,\n className,\n children,\n ...props\n}: SelectPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n alignItemWithTrigger={false}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(selectPopupVariants({ size }), className)} {...props}>\n <Base.List>{children}</Base.List>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** An option, with the tick that says it is the chosen one.\n *\n * The indicator is built in rather than left to the caller. The item already\n * reserves the room for it (`pr-7`), and a dropdown that does not show what is\n * currently selected is the commonest complaint about a styled select: it\n * opens, and the reader has to remember what they picked last time. Passing\n * `indicator={false}` turns it off for a list where the choice is obvious\n * some other way. */\nexport function SelectItem({\n indicator = true,\n className,\n children,\n ...props\n}: Base.Item.Props & { indicator?: boolean }) {\n return (\n <Base.Item className={cn(selectItemVariants(), className)} {...props}>\n {children}\n {indicator && (\n <Base.ItemIndicator className=\"absolute right-2 flex text-accent\">\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" aria-hidden>\n <path\n d=\"M3.5 8.5l3 3 6-6.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n </Base.ItemIndicator>\n )}\n </Base.Item>\n )\n}\n\n/** The caption above a group. */\nexport function SelectGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('px-2 py-1.5 text-2xs uppercase tracking-caption text-faint', className)}\n {...props}\n />\n )\n}\n\n/** A line between groups of options. */\nexport function SelectSeparator({ className, ...props }: Base.Separator.Props) {\n return <Base.Separator className={cn('-mx-1 my-1 h-px bg-line', className)} {...props} />\n}\n"
1512
1531
  }
1513
1532
  ]
1514
1533
  },
@@ -1528,13 +1547,31 @@
1528
1547
  }
1529
1548
  ]
1530
1549
  },
1550
+ {
1551
+ "name": "skeleton-of",
1552
+ "type": "registry:ui",
1553
+ "title": "Skeleton-of",
1554
+ "description": "`Skeleton` and its shapes solved half the problem: they gave a product a list, a card and a grid to reach for instead of a spinner. The half left over is the one that actually causes the jump, and it is a human one - somebody has to look at the real thing, judge how many rows it has and how tall they are, and type that in. The judgement is made once, the screen changes a month later, and the placeholder goes on promising the old shape.",
1555
+ "dependencies": [
1556
+ "dowel-ui@^0.27.0"
1557
+ ],
1558
+ "registryDependencies": [],
1559
+ "files": [
1560
+ {
1561
+ "path": "ui/skeleton-of.tsx",
1562
+ "target": "@ui/skeleton-of.tsx",
1563
+ "type": "registry:ui",
1564
+ "content": "import { useCallback, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * SkeletonOf - a placeholder that remembers the shape instead of guessing it.\n *\n * `Skeleton` and its shapes solved half the problem: they gave a product a\n * list, a card and a grid to reach for instead of a spinner. The half left\n * over is the one that actually causes the jump, and it is a human one -\n * somebody has to look at the real thing, judge how many rows it has and how\n * tall they are, and type that in. The judgement is made once, the screen\n * changes a month later, and the placeholder goes on promising the old shape.\n * The line's own calendar is the recorded case: four short lines standing in\n * for a six-row month grid, and the skeleton was itself the jump it existed to\n * prevent.\n *\n * The rule is the same one `Skeleton` is built on:\n *\n * **A skeleton of the wrong shape is worse than no skeleton.**\n *\n * What is new here is where the shape comes from. The content was on the\n * screen a moment ago; it measured itself, and nothing else knows its shape\n * better. So this keeps what was there - how many rows, how tall each one, how\n * tall the whole block - and draws that back while the next load is in flight.\n *\n * **Why measuring and not reading the source.** The obvious version of \"derive\n * the skeleton from the markup\" is to parse the component's JSX and emit\n * boxes from it. It cannot work, and the reason is worth writing down: the\n * shape on screen is not in the source. It is the source plus the data (five\n * rows or fifty), plus the viewport (one column or three), plus the theme's\n * own spacing. A generator reading `list.tsx` sees one `<li>` in a `map` and\n * knows nothing about any of that. Measuring the rendered thing sees all of it\n * at once, and it costs a `ResizeObserver` rather than a build step.\n *\n * **The first load has nothing to remember**, and that is the case a product\n * must still answer: `fallback` is what stands in until there is a measurement\n * to use. After that the fallback is never seen again on that screen.\n *\n * Everything drawn here is `aria-hidden`, like every other skeleton - the\n * loading fact belongs to the region and is said once, by whatever owns it.\n */\n\n/** What was measured: the block's own height, and the rows inside it.\n *\n * Kept as plain numbers rather than as a copy of the DOM, because that is all\n * a placeholder needs and because a detached node would pin the whole tree in\n * memory for as long as the screen lives. */\nexport interface MeasuredShape {\n /** The block's height when it was last full, in pixels. */\n height: number\n /** The height of each row inside it. Empty when nothing matched the row\n * selector - a block with no repeating part is a single box. */\n rows: number[]\n}\n\n/**\n * Measure a node: its height, and the heights of the rows inside it.\n *\n * Exported because a product occasionally wants the measurement without the\n * component - to persist it between sessions, for instance, so that even the\n * first load of a screen already knows its shape.\n *\n * Rows are found by selector rather than by walking children, because the\n * repeating part is rarely a direct child: a table's rows are inside a\n * `<tbody>`, a list's inside a `<ul>` that may itself be inside a scroller.\n */\nexport function measureShape(node: HTMLElement, rowSelector?: string): MeasuredShape {\n const height = node.getBoundingClientRect().height\n const rows = rowSelector\n ? [...node.querySelectorAll(rowSelector)].map((row) => row.getBoundingClientRect().height)\n : []\n return { height, rows }\n}\n\nexport interface SkeletonOfProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** Whether the content is on its way. While false the children are shown and\n * measured; while true the remembered shape is drawn in their place. */\n pending: boolean\n /** The real thing. Rendered and measured when not pending. */\n children: ReactNode\n /**\n * What counts as a row - `'tr'`, `'li'`, `'[data-row]'`. Given, the\n * placeholder draws one box per row at the height that row had; omitted, it\n * draws a single box the height of the whole block.\n */\n rowSelector?: string\n /**\n * What to draw before anything has been measured - the first load of a\n * screen, where there is genuinely nothing to remember. A `SkeletonList` of\n * roughly the right size is the usual answer.\n */\n fallback?: ReactNode\n /**\n * A shape measured earlier - from `measureShape`, stored by the product\n * between sessions. Used until this instance measures something itself, so\n * even a first load can be honest.\n */\n shape?: MeasuredShape\n /** The gap between drawn rows, as a Tailwind class. Matches whatever the\n * real list uses; the rows' own heights come from the measurement. */\n gapClassName?: string\n}\n\n/**\n * Shows the children, remembers their shape, and draws that shape back while\n * the next load is in flight.\n *\n * The measurement is taken in a layout effect and on every resize, not once on\n * mount: a list that gains rows, a window that narrows, a font that loads late\n * all change the shape, and a placeholder built from the first measurement\n * would be wrong in exactly the way this component exists to prevent.\n */\nexport function SkeletonOf({\n pending,\n children,\n rowSelector,\n fallback,\n shape,\n gapClassName,\n className,\n ...props\n}: SkeletonOfProps) {\n const [measured, setMeasured] = useState<MeasuredShape | undefined>(shape)\n const node = useRef<HTMLDivElement | null>(null)\n\n const remember = useCallback(() => {\n const element = node.current\n if (!element) return\n const next = measureShape(element, rowSelector)\n // A block of no height is a block that has not been laid out - a hidden\n // tab, a parent still collapsing. Remembering a zero would draw nothing\n // and claim it was the shape.\n if (next.height <= 0) return\n setMeasured((held) =>\n held && held.height === next.height && sameRows(held.rows, next.rows) ? held : next,\n )\n }, [rowSelector])\n\n useLayoutEffect(() => {\n // Only the branch that renders the content carries the ref, so while\n // pending there is nothing to measure and `remember` finds no node. That\n // is what keeps the memory from decaying: measuring the placeholder would\n // record the shape it had just drawn from memory, and each load would\n // remember a little less of what the content actually looked like.\n //\n // It is worth stating rather than relying on, because it rests on React\n // clearing a ref when the node it points at unmounts. The guard below is\n // that fact made explicit - if the component ever renders both branches,\n // this is the line that keeps the loop from closing.\n if (pending || !node.current) return\n remember()\n\n // jsdom has no ResizeObserver, and a product may run in one. The component\n // is still correct without it: it has the mount measurement, it just does\n // not follow later changes.\n if (typeof ResizeObserver === 'undefined') return\n const element = node.current\n if (!element) return\n const observer = new ResizeObserver(remember)\n observer.observe(element)\n return () => observer.disconnect()\n }, [pending, remember, children])\n\n if (!pending) {\n return (\n <div ref={node} className={className} {...props}>\n {children}\n </div>\n )\n }\n\n // Nothing measured yet, and nothing handed over: the first load of a screen\n // that did not say what it is waiting for. Saying nothing is better than\n // drawing a shape made up on the spot.\n if (!measured) return <>{fallback ?? null}</>\n\n return (\n <div aria-hidden className={cn('flex flex-col', gapClassName ?? 'gap-1', className)} {...props}>\n {measured.rows.length > 0 ? (\n measured.rows.map((height, row) => (\n <div\n key={row}\n style={{ height }}\n className=\"animate-pulse shrink-0 rounded-md bg-soft\"\n />\n ))\n ) : (\n <div style={{ height: measured.height }} className=\"animate-pulse rounded-md bg-soft\" />\n )}\n </div>\n )\n}\n\n/** Whether two row measurements are the same run of heights.\n *\n * Compared rather than replaced wholesale so that a resize that changes\n * nothing does not hand back a new object every frame - which would re-render\n * the subtree on every pixel of a window drag. */\nfunction sameRows(left: number[], right: number[]): boolean {\n return left.length === right.length && left.every((height, index) => height === right[index])\n}\n"
1565
+ }
1566
+ ]
1567
+ },
1531
1568
  {
1532
1569
  "name": "skeleton",
1533
1570
  "type": "registry:ui",
1534
1571
  "title": "Skeleton",
1535
1572
  "description": "The rule the component is built on, and the reason it takes a shape rather than filling the space:\n * **A skeleton of the wrong shape is worse than no skeleton.**\n * It promises something the content does not keep, and the promise is paid for in a jump: the page settles, the scrollbar appears, and whatever the reader was about to click has moved. Measured rather than assumed - the line's own calendar showed a list of four short lines where a six-row month grid was about to land, and the skeleton was itself the jump it existed to prevent.",
1536
1573
  "dependencies": [
1537
- "dowel-ui@^0.26.0"
1574
+ "dowel-ui@^0.27.0"
1538
1575
  ],
1539
1576
  "registryDependencies": [],
1540
1577
  "files": [
@@ -1553,7 +1590,7 @@
1553
1590
  "description": "The case for it over a NumberField is that the number does not matter much: a volume, an opacity, a weight in a search filter. Where the exact figure does matter, a slider is a worse field with more pixels - it cannot be typed into, it cannot be pasted into, and it has no state for \"empty\".",
1554
1591
  "dependencies": [
1555
1592
  "@base-ui/react",
1556
- "dowel-ui@^0.26.0"
1593
+ "dowel-ui@^0.27.0"
1557
1594
  ],
1558
1595
  "registryDependencies": [],
1559
1596
  "files": [
@@ -1572,7 +1609,7 @@
1572
1609
  "description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
1573
1610
  "dependencies": [
1574
1611
  "class-variance-authority",
1575
- "dowel-ui@^0.26.0"
1612
+ "dowel-ui@^0.27.0"
1576
1613
  ],
1577
1614
  "registryDependencies": [],
1578
1615
  "files": [
@@ -1591,7 +1628,7 @@
1591
1628
  "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.",
1592
1629
  "dependencies": [
1593
1630
  "class-variance-authority",
1594
- "dowel-ui@^0.26.0"
1631
+ "dowel-ui@^0.27.0"
1595
1632
  ],
1596
1633
  "registryDependencies": [],
1597
1634
  "files": [
@@ -1609,7 +1646,7 @@
1609
1646
  "title": "Splash",
1610
1647
  "description": "A desktop product has a second or two between the window appearing and the first screen being ready - a workspace to open, a database to migrate, a plugin to start - and a blank window for that long reads as a crash. So the window shows the product instead: the mark, the name, the promise, the version, and a bar that sweeps until there is something to draw.",
1611
1648
  "dependencies": [
1612
- "dowel-ui@^0.26.0"
1649
+ "dowel-ui@^0.27.0"
1613
1650
  ],
1614
1651
  "registryDependencies": [],
1615
1652
  "files": [
@@ -1628,7 +1665,7 @@
1628
1665
  "description": "The smallest thing on a dashboard and the one every product writes itself: a label above, a number below, sometimes a word about which way it moved.",
1629
1666
  "dependencies": [
1630
1667
  "class-variance-authority",
1631
- "dowel-ui@^0.26.0"
1668
+ "dowel-ui@^0.27.0"
1632
1669
  ],
1633
1670
  "registryDependencies": [],
1634
1671
  "files": [
@@ -1640,6 +1677,25 @@
1640
1677
  }
1641
1678
  ]
1642
1679
  },
1680
+ {
1681
+ "name": "status-dot",
1682
+ "type": "registry:ui",
1683
+ "title": "Status-dot",
1684
+ "description": "The smallest thing a screen can say about something's condition: a server is up, a job failed, a person is away. Every product of the line drew its own coloured circle, and every one of them drew it the same way - a `<span>` with a background - which means the condition existed for exactly the readers who could see it.",
1685
+ "dependencies": [
1686
+ "class-variance-authority",
1687
+ "dowel-ui@^0.27.0"
1688
+ ],
1689
+ "registryDependencies": [],
1690
+ "files": [
1691
+ {
1692
+ "path": "ui/status-dot.tsx",
1693
+ "target": "@ui/status-dot.tsx",
1694
+ "type": "registry:ui",
1695
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * StatusDot and StatusBadge.\n *\n * The smallest thing a screen can say about something's condition: a server is\n * up, a job failed, a person is away. Every product of the line drew its own\n * coloured circle, and every one of them drew it the same way - a `<span>` with\n * a background - which means the condition existed for exactly the readers who\n * could see it.\n *\n * The rule this pair is built on, and the reason a dot is not a component you\n * can call with a colour alone:\n *\n * **Colour is emphasis. The word is the message.**\n *\n * That is not a preference. About one man in twelve does not separate red from\n * green, `--good` and `--bad` are the two hues most screens rest on, and a\n * printed or projected screen loses the distinction for everybody. The theme\n * already states the rule in prose; here it is structure: `StatusDot` will not\n * render without a label, and a label that is not shown is placed where a\n * screen reader will still read it.\n *\n * `StatusBadge` is the same fact when there is room to write it out. It exists\n * separately rather than as a variant because the choice between them is about\n * the space on the screen, not about the state - and a product that has the\n * room should be nudged to use the words.\n *\n * What neither does is carry a word of its own. \"Online\" is the product's\n * vocabulary in the product's language, and a primitive that shipped English\n * strings could not be translated.\n */\n\n/** The conditions the line's vocabulary can name.\n *\n * `neutral` is not a fifth colour - it is the absence of a judgement, for the\n * states that are neither good nor bad: queued, archived, unknown. Drawing\n * those in one of the four would be a claim the product did not make. */\nexport type Status = 'good' | 'warn' | 'bad' | 'info' | 'neutral'\n\nexport const statusDotVariants = cva('inline-block shrink-0 rounded-full', {\n variants: {\n status: {\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n info: 'bg-info',\n neutral: 'bg-line-2',\n },\n size: {\n /* Beside `text-xs`, where a dot much larger than the x-height reads as a\n * bullet rather than as a state. */\n sm: 'size-1.5',\n md: 'size-2',\n lg: 'size-2.5',\n },\n },\n defaultVariants: { status: 'neutral', size: 'md' },\n})\n\n/** The status as ink rather than as a fill - what an icon standing in for the\n * dot is drawn in. Separate from the fill variants because the two are used in\n * different places and a single `cva` would have to undo one to get the\n * other. */\nconst statusInk: Record<Status, string> = {\n good: 'text-good',\n warn: 'text-warn',\n bad: 'text-bad',\n info: 'text-info',\n neutral: 'text-dim',\n}\n\nexport interface StatusDotProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,\n VariantProps<typeof statusDotVariants> {\n /**\n * What this condition is called, in the product's words. Required, and not\n * for decoration: it is what the dot means to anyone who does not see the\n * colour.\n */\n label: string\n /**\n * Whether to print the label beside the dot.\n *\n * `false` keeps it for a screen reader only - the right answer inside a\n * dense table cell, where the column heading already says what is being\n * judged. It is never dropped.\n */\n showLabel?: boolean\n /**\n * A shape drawn inside the dot's place instead of the circle - a tick, a\n * cross, an exclamation. The second channel for a reader who sees the dot\n * but not its hue, and the reason `pulse` is not the only way to differ.\n */\n icon?: ReactNode\n}\n\n/**\n * A dot, and the word it stands for.\n *\n * The word is always in the accessibility tree. When it is not printed the dot\n * is not `aria-hidden` and not `role=\"presentation\"` - it is a labelled image,\n * because a status nobody can read is the defect this component exists to\n * prevent.\n */\nexport function StatusDot({\n status,\n size,\n label,\n showLabel = false,\n icon,\n className,\n ...props\n}: StatusDotProps) {\n const mark = icon ? (\n // The icon stands where the dot would, so it takes the status as its ink\n // rather than as a fill.\n <span\n className={cn('inline-flex shrink-0 items-center justify-center', statusInk[status ?? 'neutral'])}\n aria-hidden\n >\n {icon}\n </span>\n ) : (\n <span className={statusDotVariants({ status, size })} aria-hidden />\n )\n\n return (\n <span\n className={cn('inline-flex items-center gap-1.5 whitespace-nowrap', className)}\n // Announced as one thing. Without this the dot and the word are two\n // nodes and a reader hears the label twice - once from the image, once\n // from the text beside it.\n role=\"img\"\n aria-label={label}\n {...props}\n >\n {mark}\n {showLabel && (\n <span className=\"text-xs text-dim\" aria-hidden>\n {label}\n </span>\n )}\n </span>\n )\n}\n\nexport const statusBadgeVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap',\n {\n variants: {\n status: {\n good: 'bg-good-soft text-good',\n warn: 'bg-warn-soft text-warn',\n bad: 'bg-bad-soft text-bad',\n info: 'bg-info-soft text-info',\n neutral: 'bg-soft text-dim',\n },\n },\n defaultVariants: { status: 'neutral' },\n },\n)\n\nexport interface StatusBadgeProps\n extends HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof statusBadgeVariants> {\n /** A shape before the word - the second channel, where there is one. */\n icon?: ReactNode\n /** Draw the dot before the word. Off when an `icon` is given: two marks for\n * one state is noise. */\n dot?: boolean\n}\n\n/**\n * The state, written out, on a tinted ground.\n *\n * Distinct from `Badge`, which is a label of any kind: this one is about a\n * condition, takes the same `Status` vocabulary as the dot, and is what a\n * product should reach for when the answer is \"failed\" rather than \"3\".\n *\n * No `role=\"img\"` here and no `aria-label`: the word is on the screen, and\n * naming the element as an image would replace the text a reader can already\n * hear with a duplicate of it.\n */\nexport function StatusBadge({\n status,\n icon,\n dot = true,\n className,\n children,\n ...props\n}: StatusBadgeProps) {\n const showDot = dot && !icon\n return (\n <span className={cn(statusBadgeVariants({ status }), className)} {...props}>\n {icon && (\n <span className=\"inline-flex shrink-0 items-center justify-center\" aria-hidden>\n {icon}\n </span>\n )}\n {showDot && (\n <span\n className={cn(\n 'inline-block size-1.5 shrink-0 rounded-full',\n // `currentColor` rather than the status fill: inside the badge the\n // text already carries the hue, and a second token would drift\n // from it the first time either changed.\n 'bg-current',\n )}\n aria-hidden\n />\n )}\n {children}\n </span>\n )\n}\n"
1696
+ }
1697
+ ]
1698
+ },
1643
1699
  {
1644
1700
  "name": "switch",
1645
1701
  "type": "registry:ui",
@@ -1647,7 +1703,7 @@
1647
1703
  "description": "The difference from Checkbox is not how it looks, and getting it wrong is the commonest mistake in the pair. A checkbox is an answer collected now and submitted later, with the rest of the form; a switch is a setting that applies the moment it moves. Put a switch in a form with a Save button and the reader cannot tell whether anything happened - they flipped it, and nothing said so.",
1648
1704
  "dependencies": [
1649
1705
  "@base-ui/react",
1650
- "dowel-ui@^0.26.0"
1706
+ "dowel-ui@^0.27.0"
1651
1707
  ],
1652
1708
  "registryDependencies": [],
1653
1709
  "files": [
@@ -1682,7 +1738,7 @@
1682
1738
  "description": "Parts rather than a `columns` prop, and that is the decision worth stating: a `<DataTable columns={…} rows={…} />` is quicker to write for the first table and then owns every cell in the product forever. The moment one column needs a Badge, another a link, and a third the row's own menu, the prop grows a `render` for each - at which point it is JSX with extra steps, spelt in a shape only this component understands.",
1683
1739
  "dependencies": [
1684
1740
  "class-variance-authority",
1685
- "dowel-ui@^0.26.0"
1741
+ "dowel-ui@^0.27.0"
1686
1742
  ],
1687
1743
  "registryDependencies": [
1688
1744
  "https://lacodda.github.io/dowel/r/table-sort.json"
@@ -1703,7 +1759,7 @@
1703
1759
  "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
1704
1760
  "dependencies": [
1705
1761
  "class-variance-authority",
1706
- "dowel-ui@^0.26.0"
1762
+ "dowel-ui@^0.27.0"
1707
1763
  ],
1708
1764
  "registryDependencies": [
1709
1765
  "https://lacodda.github.io/dowel/r/chip.json",
@@ -1724,7 +1780,7 @@
1724
1780
  "title": "Textarea",
1725
1781
  "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.",
1726
1782
  "dependencies": [
1727
- "dowel-ui@^0.26.0"
1783
+ "dowel-ui@^0.27.0"
1728
1784
  ],
1729
1785
  "registryDependencies": [
1730
1786
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1738,13 +1794,32 @@
1738
1794
  }
1739
1795
  ]
1740
1796
  },
1797
+ {
1798
+ "name": "tier",
1799
+ "type": "registry:ui",
1800
+ "title": "Tier",
1801
+ "description": "The tier primitive.",
1802
+ "dependencies": [
1803
+ "class-variance-authority",
1804
+ "dowel-ui@^0.27.0"
1805
+ ],
1806
+ "registryDependencies": [],
1807
+ "files": [
1808
+ {
1809
+ "path": "ui/tier.tsx",
1810
+ "target": "@ui/tier.tsx",
1811
+ "type": "registry:ui",
1812
+ "content": "import type { HTMLAttributes, KeyboardEvent, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tiers: the band a number falls in, the road of bands, and one axis of the\n * score that produced it.\n *\n * The shape comes from kilna, where a work is scored on several axes and the\n * total lands in a named band - \"draft\", \"publishable\", \"a clip\". Two more\n * products of the line grade things the same way (a health score, a review\n * verdict), which is what makes this a primitive rather than a screen.\n *\n * The three pieces answer three different questions and are separate for that\n * reason:\n *\n * - `TierBadge` - *which band is this in?* A verdict, in one word.\n * - `TierRuler` - *how far into it, and how far to the next?* The verdict\n * stops being a pronouncement out of nowhere: \"nearly a clip\" is the thing\n * someone wants to know while they are still moving marks around.\n * - `AxisBar` - *what did one axis contribute?* A row of segments read left to\n * right, which a column of numerals is not.\n *\n * The defect corrected on the way in, and the reason not to copy the donor as\n * it stands: its ruler drew the bands to scale and then laid the labels out\n * with `justify-between`, which spaces them evenly. With bands at 0, 50, 78\n * and 90 the label \"78\" sat a fifth of the bar away from the boundary it\n * named. Here the labels are placed by the same arithmetic as the bands, so\n * the distance you read is the distance there is.\n */\n\n/** A band: where it starts, and what it is called.\n *\n * `min` is on the same scale as the value - 0-100 in every consumer so far,\n * but the components do not require it: `TierRuler` takes the ends explicitly.\n * The band runs from its `min` to the next band's, and the last one runs to\n * the end. */\nexport interface Tier {\n /** Stable key, for React and for the caller's own lookups. */\n key: string\n /** What it is called, in the product's words. */\n label: string\n /** The lowest value in this band. */\n min: number\n /** How the band is coloured. `accent` is the default - a band is a position\n * on the product's own scale, not a judgement in the status vocabulary. A\n * product that means \"this one is bad\" says so. */\n status?: 'accent' | 'good' | 'warn' | 'bad' | 'info' | 'neutral'\n}\n\n/**\n * The bands in order, with the ones that cannot be drawn dropped.\n *\n * Exported because a product that has tiers usually needs them sorted\n * somewhere else too, and because sorting inside a render is the kind of thing\n * that quietly becomes three different sorts.\n */\nexport function orderedTiers(tiers: Tier[], min = 0, max = 100): Tier[] {\n return tiers\n .filter((tier) => Number.isFinite(tier.min) && tier.min >= min && tier.min <= max)\n .sort((left, right) => left.min - right.min)\n}\n\n/**\n * Which band a value is standing in.\n *\n * The highest band whose `min` the value has reached. Below every band - which\n * a caller can reach by scoring under the first `min` - the answer is\n * `undefined`, and that is a real state rather than a reason to clamp: a\n * product that shows a badge for a band nothing is in would be inventing one.\n */\nexport function tierAt(tiers: Tier[], value: number): Tier | undefined {\n const ordered = orderedTiers(tiers)\n let standing: Tier | undefined\n for (const tier of ordered) {\n if (value >= tier.min) standing = tier\n else break\n }\n return standing\n}\n\nconst tierFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n info: 'bg-info',\n neutral: 'bg-line-2',\n}\n\n/** The same bands at a quarter strength, for the stretch already passed.\n *\n * Written out rather than composed with an opacity utility, because a fill and\n * an opacity are two properties and a caller's `className` can only win\n * against one of them. */\nconst tierPassedFill: Record<NonNullable<Tier['status']>, string> = {\n accent: 'bg-accent/40',\n good: 'bg-good/40',\n warn: 'bg-warn/40',\n bad: 'bg-bad/40',\n info: 'bg-info/40',\n neutral: 'bg-line-2/40',\n}\n\nexport const tierBadgeVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap',\n {\n variants: {\n status: {\n accent: 'bg-accent-soft text-accent',\n good: 'bg-good-soft text-good',\n warn: 'bg-warn-soft text-warn',\n bad: 'bg-bad-soft text-bad',\n info: 'bg-info-soft text-info',\n neutral: 'bg-soft text-dim',\n },\n },\n defaultVariants: { status: 'accent' },\n },\n)\n\nexport interface TierBadgeProps\n extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'>,\n VariantProps<typeof tierBadgeVariants> {\n /** The band's name. */\n label: ReactNode\n /** The number behind the verdict, if the product shows it. Drawn quieter\n * than the name: the band is the answer and the score is the evidence. */\n value?: ReactNode\n}\n\n/**\n * The band, as a verdict.\n *\n * Square-cornered rather than a pill, which is what separates it from\n * `StatusBadge` at a glance: a tier is a position on a scale the product owns,\n * a status is a condition from the line's fixed vocabulary, and a screen that\n * shows both should not make the reader compare colours to tell which is\n * which.\n */\nexport function TierBadge({ label, value, status, className, ...props }: TierBadgeProps) {\n return (\n <span className={cn(tierBadgeVariants({ status }), className)} {...props}>\n {label}\n {value !== undefined && <span className=\"font-normal opacity-70\">{value}</span>}\n </span>\n )\n}\n\nexport interface TierRulerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n tiers: Tier[]\n /** Where the thing stands now. */\n value: number\n /** The ends of the road. */\n min?: number\n max?: number\n /** What the ruler as a whole is called, for a screen reader, and what to say\n * the value is. The product's words - the ruler has none. */\n label: string\n /** The value spoken instead of the bare number: \"7.8 out of 10, publishable\"\n * is what a reader needs, and only the product can phrase it. */\n valueText?: string\n /** Print the band names under the road. */\n showLabels?: boolean\n}\n\n/**\n * The bands as a road, with the value standing somewhere along it.\n *\n * Drawn to scale, which is the whole point: a band that starts at 78 sits\n * nearly four fifths along, and the gap you are looking at is the gap you have\n * to close.\n *\n * Three states per band, not two - passed, standing in, still ahead. A flat\n * wash of \"reached\" over half the bar says only that the value is not at zero;\n * picking out the band being stood in is what carries the eye to where the\n * thing actually is.\n *\n * Reported as a `meter`: it is a measurement inside a known range, which is\n * exactly what that role is for, and it means a screen reader says the value\n * without the product building a sentence out of `aria-label`.\n */\nexport function TierRuler({\n tiers,\n value,\n min = 0,\n max = 100,\n label,\n valueText,\n showLabels = true,\n className,\n ...props\n}: TierRulerProps) {\n const ordered = orderedTiers(tiers, min, max)\n const span = max - min\n\n // A road needs at least a start and one boundary on it; below that there is\n // nothing to show that a bare number would not say better.\n if (ordered.length < 2 || span <= 0) return null\n\n const at = Math.min(Math.max(value, min), max)\n /** Where a value falls along the road, as a percentage of its length. */\n const placeOf = (point: number) => ((point - min) / span) * 100\n\n return (\n <div className={cn('flex flex-col gap-1', className)} {...props}>\n <div\n role=\"meter\"\n aria-label={label}\n aria-valuemin={min}\n aria-valuemax={max}\n aria-valuenow={at}\n aria-valuetext={valueText}\n className=\"relative h-1.5 w-full overflow-hidden rounded-full bg-soft\"\n >\n {ordered.map((tier, index) => {\n const next = ordered[index + 1]\n const end = next === undefined ? max : next.min\n const width = placeOf(end) - placeOf(tier.min)\n if (width <= 0) return null\n\n const reached = value >= tier.min\n const standing = reached && (next === undefined || value < next.min)\n const status = tier.status ?? 'accent'\n\n return (\n <span\n key={tier.key}\n style={{ left: `${placeOf(tier.min)}%`, width: `${width}%` }}\n className={cn(\n 'absolute top-0 h-full',\n standing ? tierFill[status] : reached ? tierPassedFill[status] : 'bg-line-2',\n // A hairline of the page's own ground, so two adjacent bands of\n // the same colour still read as two.\n index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {/* Where the value stands. A pale core inside a dark sheath, so the\n mark keeps its contrast over a filled band as well as over the\n empty road ahead. */}\n <span\n aria-hidden\n style={{ left: `${placeOf(at)}%` }}\n className=\"absolute top-1/2 h-2.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-bg ring-2 ring-text\"\n />\n </div>\n\n {showLabels && (\n <div aria-hidden className=\"relative h-4\">\n {ordered.map((tier, index) => {\n const place = placeOf(tier.min)\n return (\n <span\n key={tier.key}\n style={{\n left: `${place}%`,\n // The first label would hang off the left edge and the last\n // off the right, so the ends align to their edge and\n // everything between is centred on its boundary.\n transform:\n index === 0\n ? 'none'\n : index === ordered.length - 1 && place >= 100\n ? 'translateX(-100%)'\n : 'translateX(-50%)',\n }}\n className={cn(\n 'absolute top-0 text-2xs',\n value >= tier.min ? 'font-medium text-dim' : 'text-faint',\n )}\n >\n {tier.label}\n </span>\n )\n })}\n </div>\n )}\n </div>\n )\n}\n\nexport interface AxisBarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {\n /** What this axis is called. */\n label: string\n /** The highest mark; one segment is drawn per whole point. */\n scale: number\n /** The mark, or `undefined` while the axis is unjudged. Not the same as\n * zero: zero is a verdict, and a blank axis is the absence of one. */\n value?: number\n /** Given, the row becomes a control. Omitted, it is a read-only reading of a\n * score someone else set. */\n onChange?: (value: number | undefined) => void\n /** The mark from which the total crosses into the next band, and what to\n * call it. Absent when no mark on this axis gets there - and then nothing is\n * drawn, because a line promising a band the axis cannot deliver is worse\n * than no line. */\n threshold?: { mark: number; label: string }\n /** What to say instead of the bare number - \"unjudged\", in the product's\n * word, when there is no mark. */\n valueText?: string\n}\n\n/**\n * One axis of a score, as a row of segments.\n *\n * Scoring is a judgement, not data entry: the useful question is \"is this a\n * seven or an eight\", and a row you click answers it in one movement where a\n * spin box asks you to read, aim and type. The filled length is also readable\n * down a column of axes, which numerals are not.\n *\n * When it takes `onChange` it reports itself as a slider rather than as a row\n * of buttons, so the arrow keys, Home and End work the way they do everywhere\n * else, and a reader hears one value in a range instead of ten unlabelled\n * buttons. The row owns the keyboard; the segments are pointer targets only,\n * or tabbing past one axis would take ten presses.\n *\n * Read-only, it is a `meter` and not a disabled slider: nothing here is\n * disabled, the number is simply a fact.\n */\nexport function AxisBar({\n label,\n scale,\n value,\n onChange,\n threshold,\n valueText,\n className,\n ...props\n}: AxisBarProps) {\n const marks = Math.max(1, Math.round(scale))\n const interactive = onChange !== undefined\n\n const step = (delta: number) => {\n if (!onChange) return\n // An unjudged axis steps onto the first or last mark rather than through\n // zero: zero is a verdict of its own, and arrowing into it by accident\n // would be one.\n const next = value === undefined ? (delta > 0 ? 1 : marks) : value + delta\n onChange(Math.min(Math.max(next, 0), marks))\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!onChange) return\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowUp':\n event.preventDefault()\n step(1)\n break\n case 'ArrowLeft':\n case 'ArrowDown':\n event.preventDefault()\n step(-1)\n break\n case 'Home':\n event.preventDefault()\n onChange(0)\n break\n case 'End':\n event.preventDefault()\n onChange(marks)\n break\n // Back to unjudged, which no arrow key can reach - and which is not the\n // same as scoring the axis zero.\n case 'Backspace':\n case 'Delete':\n event.preventDefault()\n onChange(undefined)\n break\n default:\n break\n }\n }\n\n return (\n <div\n role={interactive ? 'slider' : 'meter'}\n tabIndex={interactive ? 0 : undefined}\n aria-label={label}\n aria-valuemin={0}\n aria-valuemax={marks}\n aria-valuenow={value}\n aria-valuetext={valueText}\n onKeyDown={interactive ? onKeyDown : undefined}\n className={cn(\n 'flex gap-[3px] rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n >\n {Array.from({ length: marks }, (_, index) => {\n const mark = index + 1\n const filled = value !== undefined && mark <= value\n // The first mark that would carry the total over. The ring goes around\n // the segment rather than on its leading edge: a rule beside the last\n // mark reads as the end of the scale, which is exactly when the\n // threshold matters most.\n const crosses = threshold !== undefined && threshold.mark === mark\n\n const shared = cn(\n 'relative h-5 flex-1 rounded-sm transition-colors',\n filled ? 'bg-accent' : 'bg-soft',\n crosses && 'ring-2 ring-inset ring-good',\n )\n\n return interactive ? (\n // A `<span>` and not a `<button>`, which is what the donor used and\n // what the accessibility gate rejected: a focusable control inside a\n // `role=\"slider\"` is a nested interactive element, and `tabindex=-1`\n // with `aria-hidden` does not undo that - assistive technology can\n // still land on it, and WCAG 4.1.2 says so.\n //\n // Nothing is lost. The row already owns the keyboard, so the segment\n // was never a keyboard target; it is a pointer target, which a span\n // with a click handler is.\n <span\n key={mark}\n aria-hidden\n // Clicking the mark already set clears the axis, which is the only\n // way back to unjudged with a pointer.\n onClick={() => onChange?.(value === mark ? undefined : mark)}\n title={crosses ? threshold.label : undefined}\n className={cn(shared, 'cursor-pointer', filled ? 'hover:bg-accent-2' : 'hover:bg-line-2')}\n />\n ) : (\n <span key={mark} aria-hidden className={shared} />\n )\n })}\n </div>\n )\n}\n"
1813
+ }
1814
+ ]
1815
+ },
1741
1816
  {
1742
1817
  "name": "time-field",
1743
1818
  "type": "registry:ui",
1744
1819
  "title": "Time-field",
1745
1820
  "description": "No donor for this one: neither product of the line had a time field, so this is written from the same shape as DurationField, and for the same reason. Anything a person plausibly types is accepted - `9`, `9:30`, `930`, `9.30`, `9pm`, `21:30` - and what comes back is always `HH:MM`.",
1746
1821
  "dependencies": [
1747
- "dowel-ui@^0.26.0"
1822
+ "dowel-ui@^0.27.0"
1748
1823
  ],
1749
1824
  "registryDependencies": [
1750
1825
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1758,6 +1833,25 @@
1758
1833
  }
1759
1834
  ]
1760
1835
  },
1836
+ {
1837
+ "name": "timeline",
1838
+ "type": "registry:ui",
1839
+ "title": "Timeline",
1840
+ "description": "What happened, in the order it happened: a release history, an audit trail, the steps a job went through. Four products of the line draw one, and all four drew it the same way - a list with a border on the left and a dot positioned over it by hand.",
1841
+ "dependencies": [
1842
+ "class-variance-authority",
1843
+ "dowel-ui@^0.27.0"
1844
+ ],
1845
+ "registryDependencies": [],
1846
+ "files": [
1847
+ {
1848
+ "path": "ui/timeline.tsx",
1849
+ "target": "@ui/timeline.tsx",
1850
+ "type": "registry:ui",
1851
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Timeline.\n *\n * What happened, in the order it happened: a release history, an audit trail,\n * the steps a job went through. Four products of the line draw one, and all\n * four drew it the same way - a list with a border on the left and a dot\n * positioned over it by hand.\n *\n * Three things that version gets wrong, and which are the reason this is a\n * primitive:\n *\n * - **The rail runs past the last entry.** A border on the container ends\n * where the container does, which is below the last dot - so the history\n * trails off into a line going nowhere, and a reader cannot tell a finished\n * list from one still loading. Here the rail is drawn per entry and the last\n * one does not draw it.\n * - **It is a list, and says so.** A stack of `<div>`s is announced as nothing;\n * an `<ol>` tells a reader how many entries there are and which one they are\n * on, which is the whole navigational value of a history.\n * - **The state is a colour.** A red dot for a failed step is invisible to the\n * reader it matters most to. The marker takes the same `Status` vocabulary as\n * `StatusDot`, and the same rule applies: the colour is emphasis and the\n * entry's own words are the message.\n *\n * The rail is drawn with a `<span>` rather than a border on the item, because\n * the segment has to start below the dot and run to the next one - a border\n * would start at the item's top edge and cut through the marker.\n */\n\nexport type TimelineStatus = 'good' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent'\n\nexport const timelineMarkerVariants = cva(\n 'relative z-1 flex shrink-0 items-center justify-center rounded-full',\n {\n variants: {\n status: {\n accent: 'bg-accent text-on-accent',\n good: 'bg-good text-on-good',\n warn: 'bg-warn text-on-warn',\n bad: 'bg-bad text-on-bad',\n info: 'bg-info text-on-info',\n neutral: 'bg-line-2 text-dim',\n },\n /** A step not taken yet: the ring of a dot with nothing in it, which is\n * how every other tool draws \"not done\" and is worth borrowing. */\n pending: {\n true: 'border-2 border-line-2 bg-bg',\n false: '',\n },\n },\n defaultVariants: { status: 'neutral', pending: false },\n },\n)\n\nexport interface TimelineProps extends HTMLAttributes<HTMLOListElement> {\n /** Draw the rail and the markers on the right of the text, for a right-to-left\n * reading or a history beside a wider body of content. */\n side?: 'start' | 'end'\n}\n\n/**\n * The list itself.\n *\n * An ordered list, because the order is the content. A product that shows the\n * newest first hands them over in that order - the component does not sort,\n * since \"newest first\" and \"the order it happened\" are both right and only the\n * product knows which it means.\n */\nexport function Timeline({ side, className, children, ...props }: TimelineProps) {\n // Defaulted here rather than in the parameter list: a string default in the\n // props reads to the word gate as English shipped inside a primitive, and\n // the rule it enforces is worth more than the shorter spelling.\n const on = side ?? 'start'\n return (\n <ol\n className={cn('flex flex-col', on === 'end' && 'items-end', className)}\n data-side={on}\n {...props}\n >\n {children}\n </ol>\n )\n}\n\nexport interface TimelineItemProps\n extends Omit<HTMLAttributes<HTMLLIElement>, 'title'>,\n VariantProps<typeof timelineMarkerVariants> {\n /** The headline of the entry - what happened. */\n title: ReactNode\n /** When it happened. Drawn quieter, and given as a node so the product can\n * pass its own `RelativeTime` rather than a string it formatted early. */\n time?: ReactNode\n /** A shape inside the marker - a tick, a cross, a number. The second\n * channel, for a reader who does not separate the hues. */\n icon?: ReactNode\n /** The last entry does not draw a rail below it. Set by the product, because\n * only it knows whether the history ends here or merely stops being shown. */\n last?: boolean\n /** Names the state in the product's words, for a reader who does not see the\n * colour. Required whenever `status` carries meaning the title does not\n * already say. */\n statusLabel?: string\n}\n\n/**\n * One entry: a marker, a rail down to the next, and what happened.\n *\n * The rail belongs to this entry rather than to the list, so a history can end\n * cleanly - and so an entry can be rendered on its own in a test or a preview\n * without a container to inherit a border from.\n */\nexport function TimelineItem({\n title,\n time,\n icon,\n status,\n pending,\n last = false,\n statusLabel,\n className,\n children,\n ...props\n}: TimelineItemProps) {\n return (\n <li className={cn('flex gap-3', className)} {...props}>\n {/* The column runs the full height of the entry, gap included. The\n spacing between entries is on the text beside it rather than on the\n `<li>`, and that is not a detail of taste: with the padding on the\n item, the column stops above it and the rail stops with it - leaving\n a gap of exactly the padding between one rail and the next marker.\n Measured on the stand at 18-22px per entry, and invisible to a test\n that counts rails rather than asking where they reach. */}\n <div className=\"relative flex flex-col items-center self-stretch\">\n <span\n className={cn(\n timelineMarkerVariants({ status, pending }),\n // Room for a glyph when there is one; a bare dot stays small\n // enough to read as a point on a line rather than as a bullet.\n icon ? 'mt-0.5 size-5 text-[10px]' : 'mt-1.5 size-2.5',\n )}\n // The marker is decorative when the state is not named. When it is,\n // the name is what a reader gets in place of the colour.\n role={statusLabel ? 'img' : undefined}\n aria-label={statusLabel}\n aria-hidden={statusLabel ? undefined : true}\n >\n {icon}\n </span>\n {!last && (\n // From just under the marker to the bottom of the entry, which is\n // where the next marker begins.\n <span aria-hidden className=\"mt-1 w-px flex-1 bg-line\" />\n )}\n </div>\n\n <div className={cn('flex min-w-0 flex-1 flex-col gap-0.5', last ? 'pb-0.5' : 'pb-4')}>\n <div className=\"flex flex-wrap items-baseline gap-x-2 gap-y-0.5\">\n <span className=\"text-sm text-text\">{title}</span>\n {time && <span className=\"text-2xs text-faint\">{time}</span>}\n </div>\n {children && <div className=\"text-xs text-dim\">{children}</div>}\n </div>\n </li>\n )\n}\n"
1852
+ }
1853
+ ]
1854
+ },
1761
1855
  {
1762
1856
  "name": "toast",
1763
1857
  "type": "registry:ui",
@@ -1766,7 +1860,7 @@
1766
1860
  "dependencies": [
1767
1861
  "@base-ui/react",
1768
1862
  "class-variance-authority",
1769
- "dowel-ui@^0.26.0"
1863
+ "dowel-ui@^0.27.0"
1770
1864
  ],
1771
1865
  "registryDependencies": [],
1772
1866
  "files": [
@@ -1774,7 +1868,7 @@
1774
1868
  "path": "ui/toast.tsx",
1775
1869
  "target": "@ui/toast.tsx",
1776
1870
  "type": "registry:ui",
1777
- "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"
1871
+ "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-raise',\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"
1778
1872
  }
1779
1873
  ]
1780
1874
  },
@@ -1786,7 +1880,7 @@
1786
1880
  "dependencies": [
1787
1881
  "@base-ui/react",
1788
1882
  "class-variance-authority",
1789
- "dowel-ui@^0.26.0"
1883
+ "dowel-ui@^0.27.0"
1790
1884
  ],
1791
1885
  "registryDependencies": [],
1792
1886
  "files": [
@@ -1794,7 +1888,7 @@
1794
1888
  "path": "ui/tooltip.tsx",
1795
1889
  "target": "@ui/tooltip.tsx",
1796
1890
  "type": "registry:ui",
1797
- "content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-float',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-[3px] data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-[3px] data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-[3px] data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-[3px] data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1891
+ "content": "import { Tooltip as Base } from '@base-ui/react/tooltip'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Tooltip.\n *\n * A label for a control that has no room for one - an icon button, a truncated\n * cell, a symbol whose meaning is not obvious. A phrase, not a panel.\n *\n * The line between this and Popover is not size, it is whether anything inside\n * can be reached. A tooltip holds no links, no buttons and no fields, because\n * there is no way to get into it: it is tied to a trigger that is hovered or\n * focused, and it disappears the moment attention moves. Content that has to\n * be clicked belongs in a Popover, whatever its length.\n *\n * The half everyone forgets is the keyboard. A tooltip that only opens on\n * hover is invisible to anyone who tabs, which is precisely the person reading\n * an unlabelled icon button. Base UI opens it on focus as well, and `Escape`\n * dismisses it - so it is tested here rather than assumed.\n *\n * The thing to know before using it: this is a VISUAL label, and nothing else.\n * Base UI puts no `role=\"tooltip\"` on the popup and no `aria-describedby` on\n * the trigger, and that is deliberate rather than an oversight - a tooltip is\n * unreachable on a touch screen and unreliable for a screen reader, so\n * pretending otherwise would be worse than not trying. What follows from that\n * is a rule, not a suggestion: THE TRIGGER MUST CARRY ITS OWN `aria-label`,\n * saying roughly what the tooltip says. The tooltip helps a sighted mouse or\n * keyboard user; the `aria-label` is what everybody else gets.\n *\n * So if the words are load-bearing - if not reading them means not\n * understanding the control - this is the wrong component. Put them inline, or\n * in a Popover with `openOnHover` on the trigger, which touch and screen\n * readers can actually reach.\n *\n * `Provider` is optional and shared: once one tooltip in a group has opened,\n * the next opens instantly instead of waiting out its delay again. A toolbar\n * of icon buttons without it feels broken in a way nobody can name.\n */\n\nexport const tooltipPopupVariants = cva(\n [\n 'rounded-md border border-line bg-raise px-2 py-1 text-xs text-text shadow-raise',\n 'select-none',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.96] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0',\n // Grow out of the edge it is anchored to rather than out of its own\n // middle, so the motion points back at the trigger.\n 'origin-[var(--transform-origin)]',\n ],\n {\n variants: {\n /*\n * Two, and no more. A tooltip is a phrase; the only real decision is\n * whether it is allowed to wrap.\n *\n * `wide` exists because the alternative people reach for is a Popover\n * that nothing can be clicked in, which loses the hover and focus\n * behaviour to gain a width.\n */\n size: {\n sm: 'max-w-[16rem]',\n wide: 'max-w-[24rem]',\n },\n },\n defaultVariants: { size: 'sm' },\n },\n)\n\n/** A shared delay for a group of tooltips. Wrap a toolbar in it and the second\n * icon button explains itself instantly rather than making the reader wait\n * again. Optional - a lone tooltip works without one. */\nexport const TooltipProvider = Base.Provider\n\n/** The root. Takes `disabled`, and the controlled `open`/`onOpenChange`.\n *\n * Note that `delay` is NOT here - it is a prop of the trigger, the same as on\n * PreviewCard. Passing it to the root is silently ignored at runtime, which is\n * exactly the kind of mistake only the type checker catches. */\nexport const Tooltip = Base.Root\n\n/** What it labels. Give it `render` to use your own button - and give that\n * button an `aria-label` saying what the tooltip says, because the tooltip\n * itself reaches nobody using a screen reader. */\nexport const TooltipTrigger = Base.Trigger\n\nexport interface TooltipPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof tooltipPopupVariants> {\n /** Preferred side of the trigger. Base UI flips it when it does not fit,\n * and defaults it to the top. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. Base UI centres it by default. */\n align?: Base.Positioner.Props['align']\n /** Distance from the trigger, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /** Whether to draw the arrow pointing back at the trigger. */\n arrow?: boolean\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by an ancestor. Pass an element to put it\n * somewhere else - inside an overlay that is already open, or into a\n * container being screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The label itself. Portalled and positioned, so it is not clipped by an\n * ancestor with `overflow: hidden`.\n *\n * `--z-popup` rather than `--z-floating`: a tooltip labels whatever is on top\n * of it, including the contents of a popover or a dialog, so it has to be able\n * to sit above them. */\nexport function TooltipPopup({\n size,\n side,\n align,\n sideOffset = 6,\n arrow = true,\n container,\n className,\n children,\n ...props\n}: TooltipPopupProps) {\n return (\n <Base.Portal container={container}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n className=\"[z-index:var(--z-popup)]\"\n >\n <Base.Popup className={cn(tooltipPopupVariants({ size }), className)} {...props}>\n {arrow ? <TooltipArrow /> : null}\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The notch pointing back at the trigger. Base UI rotates it to whatever side\n * the tooltip landed on, which is why the placement is keyed off `data-side`\n * rather than off the `side` that was asked for. */\nexport function TooltipArrow({ className, ...props }: Base.Arrow.Props) {\n return (\n <Base.Arrow\n className={cn(\n 'h-1.5 w-1.5 rotate-45 border border-line bg-raise',\n /* Two of the four borders are dropped per side, which Popover has done\n * all along and this did not.\n *\n * A square rotated 45 degrees shows two of its edges outside the\n * popup: the pair facing the trigger, which is the notch, and the\n * pair behind it, which is a stray line hanging off the far side. It\n * reads as a second arrow pointing the wrong way - and it is small\n * enough that it looked like a rendering artefact rather than a rule\n * nobody wrote. */\n 'data-[side=bottom]:-top-[3px] data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0',\n 'data-[side=top]:-bottom-[3px] data-[side=top]:border-t-0 data-[side=top]:border-l-0',\n 'data-[side=left]:-right-[3px] data-[side=left]:border-b-0 data-[side=left]:border-l-0',\n 'data-[side=right]:-left-[3px] data-[side=right]:border-r-0 data-[side=right]:border-t-0',\n className,\n )}\n {...props}\n />\n )\n}\n"
1798
1892
  }
1799
1893
  ]
1800
1894
  },
@@ -1821,7 +1915,7 @@
1821
1915
  "description": "Two products had written this independently and arrived at the same construction - a rounded track, segments positioned absolutely by percent, a floor under the segment width so a short one does not vanish - differing only in what a segment meant. One drew the tiers of a rubric with the score standing among them; the other drew a working day as alternating work and breaks. Neither could be built from the other, and each knew something the other did not: the tiers had the marker and the three-state reading of a segment (passed, standing in, still ahead), the day had the minimum width and the difference between an empty track and an unknown one.",
1822
1916
  "dependencies": [
1823
1917
  "class-variance-authority",
1824
- "dowel-ui@^0.26.0"
1918
+ "dowel-ui@^0.27.0"
1825
1919
  ],
1826
1920
  "registryDependencies": [
1827
1921
  "https://lacodda.github.io/dowel/r/track-segments.json"
@@ -1857,7 +1951,7 @@
1857
1951
  "title": "Tree-view",
1858
1952
  "description": "The shape products reach for and then get wrong in the same place every time. A tree is not a nest of lists with click handlers - it is one control with a cursor in it, and the difference is the whole component:\n * **One tab stop, not one per node.** A tree of four hundred files with a `tabIndex` on each is four hundred stops between the sidebar and the editor. The container is what the keyboard reaches, and the arrows move a cursor inside it - the arrangement a `RadioGroup` has, for the same reason.",
1859
1953
  "dependencies": [
1860
- "dowel-ui@^0.26.0"
1954
+ "dowel-ui@^0.27.0"
1861
1955
  ],
1862
1956
  "registryDependencies": [
1863
1957
  "https://lacodda.github.io/dowel/r/tree-rows.json"
@@ -1877,7 +1971,7 @@
1877
1971
  "title": "Truncate",
1878
1972
  "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.",
1879
1973
  "dependencies": [
1880
- "dowel-ui@^0.26.0"
1974
+ "dowel-ui@^0.27.0"
1881
1975
  ],
1882
1976
  "registryDependencies": [],
1883
1977
  "files": [
@@ -1895,7 +1989,7 @@
1895
1989
  "title": "Virtual-list",
1896
1990
  "description": "The browser is fine with long lists until it is not: a hundred thousand `<div>`s is a layout the machine recomputes on every change, and the page stops responding while it does. What is drawn instead is the window the reader can actually see, held in place by a tall spacer, so the scrollbar still says how much there is.",
1897
1991
  "dependencies": [
1898
- "dowel-ui@^0.26.0"
1992
+ "dowel-ui@^0.27.0"
1899
1993
  ],
1900
1994
  "registryDependencies": [],
1901
1995
  "files": [
@@ -1914,7 +2008,7 @@
1914
2008
  "description": "With `decorations: false` the system draws nothing, so everything it used to do is the page's: dragging the window by its title bar, double-click to maximise, the three buttons, and the edges you grab to resize. Each is small; the reason to take them on at all is that a system title bar over an application title bar costs a strip of every laptop screen for nothing.",
1915
2009
  "dependencies": [
1916
2010
  "@tauri-apps/api",
1917
- "dowel-ui@^0.26.0"
2011
+ "dowel-ui@^0.27.0"
1918
2012
  ],
1919
2013
  "registryDependencies": [],
1920
2014
  "files": [