dowel-ui 0.24.0 → 0.26.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,11 +14,27 @@
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 /* 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 --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 --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 /* 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. `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"
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; }"
21
21
  },
22
+ {
23
+ "extends": "none",
24
+ "name": "prose",
25
+ "type": "registry:style",
26
+ "title": "dowel prose",
27
+ "description": "The shape of text a product did not write by hand: rendered markdown, a description from a CMS, a model's reply. One class on the container, and the tags inside it - headings, lists, code, tables, quotes - are drawn in the line's tokens.",
28
+ "files": [
29
+ {
30
+ "path": "dowel/prose.css",
31
+ "target": "~/dowel/prose.css",
32
+ "type": "registry:file",
33
+ "content": "/*\n * dowel prose - the shape of text a product did not write by hand.\n *\n * Every other rule in this system is applied by a component, because a\n * component owns the element it draws. Rendered markdown is the case where\n * that is impossible: the product hands the DOM a string of HTML - from\n * `marked`, from a CMS, from a model's reply - and there is no React element\n * to hang a class on. The tags arrive already made. Only a descendant selector\n * reaches them.\n *\n * kilna proved the shape before this file existed, as thirty `[&_h1]:mt-4`\n * arbitrary variants inside one `className` string. It works and it is a\n * paragraph of unreadable text that no second product can share, which is the\n * whole argument for moving it here.\n *\n * Scoped to `.prose`, so nothing leaks: a stylesheet that styled `h2`\n * globally would reach into every component that happens to render one.\n *\n * WHY NOT A TYPOGRAPHY PLUGIN. `@tailwindcss/typography` answers the same\n * question and brings its own answer to a different one - its own type scale,\n * its own greys, its own idea of measure. Installing it next to this theme\n * means two vocabularies describing the same text, and the one that wins is\n * whichever loaded last. Every value below is a token from `theme.css`; there\n * is not a single colour or size written down here.\n *\n * WHAT THIS IS NOT FOR. Interface text - a label, a row, a button - is styled\n * by the component that draws it. `.prose` is for a reading column: a note, a\n * description, an article, a chat reply. Wrapping a form in it is how a screen\n * ends up with two competing ideas of what `text-sm` means.\n *\n * Import after the theme:\n *\n * @import 'tailwindcss';\n * @import './dowel/theme.css';\n * @import './dowel/prose.css';\n */\n\n.prose {\n /*\n * The reading size is `--text-base`, not the `--text-sm` the interface runs\n * at, and the two are different on purpose. Chrome is scanned - a label is\n * recognised rather than read - and it packs tighter the less of it there\n * is. Prose is read word by word, and 12px of continuous text is where a\n * reader starts leaning in. The products already knew this and did it by\n * hand: kilna's rendered markdown sets `text-sm` (12px) against an interface\n * of 11px, the same one-step lift.\n */\n font-size: var(--text-base);\n\n /*\n * Line height is set here rather than inherited from the size token, which\n * carries 20px for 14px text - right for a label, tight for a paragraph.\n * 1.65 is the ratio the eye returns to the start of the next line with; it\n * is a ratio rather than a length so a product that scales the size up for a\n * reading view keeps the proportion.\n */\n line-height: 1.65;\n color: var(--text);\n\n /*\n * A measure, not a width. Prose is unreadable across a wide window - the eye\n * loses the line it is returning from - and `ch` is the unit that says so in\n * the terms the limit is actually about: characters, at whatever size the\n * text is drawn. 68 is inside the 45-75 the typographic literature agrees\n * on, at the wide end because these are technical texts with code and long\n * identifiers in them.\n *\n * A product that has its own column - a chat bubble, a card - overrides\n * `max-width` and loses nothing else.\n */\n max-width: 68ch;\n\n /*\n * The shell of a desktop app usually turns selection off, so that dragging\n * inside a window moves the window. Text someone came to READ has to hand it\n * back, or the one thing a reader wants to do with a paragraph - take a\n * sentence out of it - is the thing the app forbids.\n */\n user-select: text;\n -webkit-user-select: text;\n}\n\n/*\n * Vertical rhythm.\n *\n * Margins collapse between siblings, so stating both a top and a bottom on\n * every block would double the gap at some joins and not others depending on\n * which value was larger. Instead: one bottom margin on everything, and the\n * top margin belongs to the headings alone, which are the only elements that\n * need more space above them than below - a heading belongs to what follows\n * it, and sitting equidistant between two paragraphs it appears to belong to\n * neither.\n */\n.prose > * {\n margin-block: 0 0.75em;\n}\n\n/* The first and last child never push the container open: a rendered note\n * inside a bordered card would otherwise have a gap at the top that the card's\n * own padding did not put there. */\n.prose > :first-child {\n margin-block-start: 0;\n}\n.prose > :last-child {\n margin-block-end: 0;\n}\n\n/*\n * Headings.\n *\n * The scale is the theme's, stepped down from a document's h1 - and it starts\n * lower than a web page's would, because prose here is almost always a section\n * INSIDE a screen that already has a title. An h1 drawn at 21px next to a page\n * heading of 18px makes the note look like the more important thing on screen.\n *\n * `text-wrap: balance` on headings only: it is expensive on long text and\n * makes a two-line heading break in the middle rather than leaving one word\n * alone on the second line.\n */\n.prose :is(h1, h2, h3, h4, h5, h6) {\n margin-block: 1.6em 0.5em;\n font-weight: var(--font-weight-semibold);\n line-height: 1.3;\n text-wrap: balance;\n /* A heading that ends up at the top of a scrolled container should not be\n * flush against its edge. */\n scroll-margin-block-start: 1rem;\n}\n\n.prose h1 {\n font-size: var(--text-xl);\n letter-spacing: var(--tracking-tight);\n}\n.prose h2 {\n font-size: var(--text-lg);\n letter-spacing: var(--tracking-tight);\n}\n.prose h3 {\n font-size: var(--text-base);\n}\n\n/*\n * h4 and below stop growing and start differentiating by other means: a\n * document nested six levels deep has run out of sizes long before it runs out\n * of levels, and inventing two more steps inside four pixels is the noise the\n * type scale exists to remove. They are the body size, set apart by weight and\n * by colour.\n */\n.prose :is(h4, h5, h6) {\n font-size: var(--text-base);\n color: var(--dim);\n}\n\n/* A heading directly after another has nothing between them to separate. */\n.prose :is(h1, h2, h3, h4, h5, h6) + :is(h1, h2, h3, h4, h5, h6) {\n margin-block-start: 0.8em;\n}\n\n/*\n * Lists.\n *\n * Tailwind's preflight strips the markers, which is right for the interface -\n * a menu is a `ul` and must not have bullets - and wrong here, where a list is\n * a list. They are put back, and `outside` so the marker hangs in the indent\n * and the text of a wrapped item lines up with itself rather than with the\n * bullet.\n */\n.prose :is(ul, ol) {\n padding-inline-start: 1.5em;\n}\n.prose ul {\n list-style: disc;\n}\n.prose ol {\n list-style: decimal;\n}\n/*\n * `--dim`, not `--faint`, and that was measured rather than chosen.\n *\n * A bullet looks like furniture, so the faintest token is the instinct. But\n * the marker is what says \"this is a list\" - drop it and an ordered list loses\n * its numbers, which are content. On the stand it came out at 3.17:1 against\n * the surface, below the 4.5:1 that anything carrying meaning has to clear,\n * and it read as a smudge at the size a bullet actually is. `--dim` puts it at\n * 6.2:1 and still sits back from the text.\n */\n.prose li::marker {\n color: var(--dim);\n}\n.prose li {\n margin-block: 0.25em;\n}\n\n/* A nested list belongs to the item above it, not to the gap after it. */\n.prose li > :is(ul, ol) {\n margin-block: 0.25em;\n}\n\n/* A task list from markdown: the checkbox replaces the marker, so the bullet\n * beside it would be a second one saying the same thing. */\n.prose li:has(> input[type='checkbox']:first-child) {\n list-style: none;\n margin-inline-start: -1.25em;\n}\n.prose li > input[type='checkbox'] {\n margin-inline-end: 0.4em;\n accent-color: var(--accent);\n}\n\n/*\n * Inline code.\n *\n * `0.9em` rather than a token: a monospaced face at the same nominal size as\n * the text around it looks larger, because its lowercase letters are taller\n * relative to the em. The correction is proportional to whatever size the\n * surrounding text happens to be, which a fixed token could not follow.\n */\n.prose code {\n font-family: var(--font-mono);\n font-size: 0.9em;\n background-color: var(--soft);\n border-radius: var(--radius-xs);\n padding: 0.15em 0.35em;\n /* A long identifier in the middle of a sentence must be allowed to break,\n * or it pushes the whole column wider than its measure. */\n overflow-wrap: anywhere;\n}\n\n/*\n * A code block is not a big inline code. The padding, background and radius\n * belong to the `pre`; the `code` inside it gives them all up, or the block\n * gets a second inset panel drawn inside itself - which is exactly what\n * happens when a typography plugin's inline rule is left to apply here.\n *\n * `overflow-x: auto` rather than wrapping: a wrapped line of code is a line\n * that lies about where it ends, and indentation is how code is read.\n */\n.prose pre {\n font-family: var(--font-mono);\n font-size: var(--text-sm);\n line-height: 1.55;\n background-color: var(--soft);\n border: 1px solid var(--line);\n border-radius: var(--radius-md);\n padding: 0.75rem 0.85rem;\n overflow-x: auto;\n /* `tab-size: 2` is the line's own indent; the browser default of 8 turns a\n * tab-indented file into a horizontal scroll for nothing. */\n tab-size: 2;\n}\n\n.prose pre code {\n background-color: transparent;\n border-radius: 0;\n padding: 0;\n font-size: inherit;\n /* Inside a scrolling block, breaking a long line would defeat the scroll. */\n overflow-wrap: normal;\n}\n\n/*\n * Links.\n *\n * `--accent-2` rather than `--accent`: on the dark theme the accent is the\n * product's own colour at full strength, which against body text reads as a\n * button that failed to draw. The partner shade is the one the products use\n * for a link, and it is also the one with room to darken on hover.\n *\n * Underlined, always. Colour alone is not a link - a reader who does not see\n * the hue gets no signal at all - and this is the one place in the system\n * where the rule \"meaning never rests on colour\" has a standard answer.\n */\n.prose a {\n color: var(--accent-2);\n text-decoration: underline;\n /* The underline drops below the descenders instead of striking through\n * them, which is the difference between a link and a crossed-out word. */\n text-underline-offset: 0.2em;\n text-decoration-thickness: from-font;\n overflow-wrap: anywhere;\n}\n\n.prose a:hover {\n color: var(--accent);\n}\n\n/*\n * A quotation.\n *\n * A rule on the left and dimmed text, rather than italics: a blockquote is\n * frequently a paragraph or more, and a long passage in italic is slower to\n * read for everyone and materially harder for some dyslexic readers.\n */\n.prose blockquote {\n border-inline-start: 2px solid var(--line-2);\n padding-inline-start: 0.9em;\n color: var(--dim);\n}\n\n/*\n * A table inside prose.\n *\n * `display: block` with its own scroll, because the one thing a table must not\n * do in a reading column is set the column's width: a note with a six-column\n * table in it would push every paragraph around it out to the table's width.\n * The cost is stated - a block-level table no longer participates in the\n * column's own layout - and it is the right trade for text.\n */\n.prose table {\n display: block;\n max-width: 100%;\n overflow-x: auto;\n border-collapse: collapse;\n font-size: var(--text-sm);\n}\n\n.prose :is(th, td) {\n border: 1px solid var(--line);\n padding: 0.3em 0.55em;\n text-align: start;\n vertical-align: top;\n}\n\n.prose th {\n background-color: var(--soft);\n font-weight: var(--font-weight-semibold);\n}\n\n/*\n * A horizontal rule is a section break, so the space around it is the point;\n * a hairline with a paragraph's gap either side reads as a mistake.\n */\n.prose hr {\n border: 0;\n border-block-start: 1px solid var(--line);\n margin-block: 2em;\n}\n\n.prose :is(strong, b) {\n font-weight: var(--font-weight-semibold);\n color: var(--text);\n}\n\n.prose :is(em, i) {\n font-style: italic;\n}\n\n.prose :is(s, del) {\n color: var(--dim);\n}\n\n.prose mark {\n background-color: var(--accent-soft);\n color: inherit;\n border-radius: var(--radius-xs);\n padding: 0.05em 0.2em;\n}\n\n/*\n * An image in prose is never wider than the column, and keeps its ratio when\n * it is constrained. `display: block` because an inline image sits on the text\n * baseline and leaves a strip of descender space under it that looks like a\n * broken margin.\n */\n.prose img {\n display: block;\n max-width: 100%;\n height: auto;\n border-radius: var(--radius-sm);\n}\n\n/*\n * `kbd` is drawn as a key rather than as code: a reader who sees `Ctrl` in the\n * same grey box as a variable name has to work out which it is. This matches\n * the Kbd primitive, so a key looks the same whether a component drew it or\n * markdown did.\n */\n.prose kbd {\n font-family: var(--font-mono);\n font-size: 0.85em;\n background-color: var(--raise);\n border: 1px solid var(--line-2);\n border-block-end-width: 2px;\n border-radius: var(--radius-xs);\n padding: 0.1em 0.35em;\n color: var(--dim);\n}\n\n/*\n * A definition list, which markdown does not produce but a CMS does.\n */\n.prose dt {\n font-weight: var(--font-weight-semibold);\n margin-block-start: 0.75em;\n}\n.prose dd {\n margin-inline-start: 1.5em;\n color: var(--dim);\n}\n\n/*\n * A footnote reference and the notes at the bottom - what `remark-gfm`\n * produces. Smaller and dimmer, because a footnote that reads at the weight of\n * the text interrupts the sentence carrying it.\n */\n.prose sup a {\n text-decoration: none;\n font-size: 0.8em;\n}\n.prose .footnotes {\n font-size: var(--text-sm);\n color: var(--dim);\n border-block-start: 1px solid var(--line);\n margin-block-start: 2em;\n padding-block-start: 0.75em;\n}\n\n/*\n * Tight: the same prose in a place that has no room for a reading column - a\n * chat bubble, a table cell, a hover card. The rhythm compresses and the\n * measure is given up to the container, because in a bubble the container IS\n * the measure. Nothing else changes: the same tags, the same tokens.\n */\n.prose-tight {\n font-size: var(--text-sm);\n line-height: 1.55;\n max-width: none;\n}\n\n.prose-tight > * {\n margin-block: 0 0.5em;\n}\n\n.prose-tight :is(h1, h2, h3, h4, h5, h6) {\n margin-block: 1em 0.35em;\n}\n\n.prose-tight :is(h1, h2) {\n font-size: var(--text-base);\n}\n\n.prose-tight h3 {\n font-size: var(--text-sm);\n}\n\n.prose-tight hr {\n margin-block: 1.2em;\n}\n"
34
+ }
35
+ ],
36
+ "docs": "Import it after the theme, then put the class on whatever holds the HTML:\n\n @import './dowel/theme.css';\n @import './dowel/prose.css';\n\n <div className=\"prose\" dangerouslySetInnerHTML={{ __html: html }} />\n\n`prose-tight` is the same rules with the rhythm compressed and the measure\ngiven up - for a chat bubble, a table cell, a hover card.\n\nSanitise the HTML before it reaches the DOM. This stylesheet draws markup;\nit does not make it safe."
37
+ },
22
38
  {
23
39
  "name": "accent-kasl",
24
40
  "type": "registry:file",
@@ -293,7 +309,7 @@
293
309
  "dependencies": [
294
310
  "@base-ui/react",
295
311
  "class-variance-authority",
296
- "dowel-ui@^0.24.0"
312
+ "dowel-ui@^0.26.0"
297
313
  ],
298
314
  "registryDependencies": [],
299
315
  "files": [
@@ -312,7 +328,7 @@
312
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.",
313
329
  "dependencies": [
314
330
  "class-variance-authority",
315
- "dowel-ui@^0.24.0"
331
+ "dowel-ui@^0.26.0"
316
332
  ],
317
333
  "registryDependencies": [
318
334
  "https://lacodda.github.io/dowel/r/activity-weeks.json"
@@ -332,7 +348,7 @@
332
348
  "title": "Activity-legend",
333
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.",
334
350
  "dependencies": [
335
- "dowel-ui@^0.24.0"
351
+ "dowel-ui@^0.26.0"
336
352
  ],
337
353
  "registryDependencies": [
338
354
  "https://lacodda.github.io/dowel/r/activity-heatmap.json",
@@ -370,7 +386,7 @@
370
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.",
371
387
  "dependencies": [
372
388
  "class-variance-authority",
373
- "dowel-ui@^0.24.0"
389
+ "dowel-ui@^0.26.0"
374
390
  ],
375
391
  "registryDependencies": [],
376
392
  "files": [
@@ -389,7 +405,7 @@
389
405
  "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.",
390
406
  "dependencies": [
391
407
  "class-variance-authority",
392
- "dowel-ui@^0.24.0"
408
+ "dowel-ui@^0.26.0"
393
409
  ],
394
410
  "registryDependencies": [],
395
411
  "files": [
@@ -408,7 +424,7 @@
408
424
  "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.",
409
425
  "dependencies": [
410
426
  "class-variance-authority",
411
- "dowel-ui@^0.24.0"
427
+ "dowel-ui@^0.26.0"
412
428
  ],
413
429
  "registryDependencies": [],
414
430
  "files": [
@@ -427,7 +443,7 @@
427
443
  "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.",
428
444
  "dependencies": [
429
445
  "class-variance-authority",
430
- "dowel-ui@^0.24.0"
446
+ "dowel-ui@^0.26.0"
431
447
  ],
432
448
  "registryDependencies": [],
433
449
  "files": [
@@ -447,7 +463,7 @@
447
463
  "dependencies": [
448
464
  "@base-ui/react",
449
465
  "class-variance-authority",
450
- "dowel-ui@^0.24.0"
466
+ "dowel-ui@^0.26.0"
451
467
  ],
452
468
  "registryDependencies": [],
453
469
  "files": [
@@ -481,7 +497,7 @@
481
497
  "title": "Calendar",
482
498
  "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.",
483
499
  "dependencies": [
484
- "dowel-ui@^0.24.0"
500
+ "dowel-ui@^0.26.0"
485
501
  ],
486
502
  "registryDependencies": [
487
503
  "https://lacodda.github.io/dowel/r/calendar-math.json"
@@ -502,7 +518,7 @@
502
518
  "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.",
503
519
  "dependencies": [
504
520
  "@base-ui/react",
505
- "dowel-ui@^0.24.0"
521
+ "dowel-ui@^0.26.0"
506
522
  ],
507
523
  "registryDependencies": [],
508
524
  "files": [
@@ -521,7 +537,7 @@
521
537
  "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.",
522
538
  "dependencies": [
523
539
  "class-variance-authority",
524
- "dowel-ui@^0.24.0"
540
+ "dowel-ui@^0.26.0"
525
541
  ],
526
542
  "registryDependencies": [],
527
543
  "files": [
@@ -533,13 +549,34 @@
533
549
  }
534
550
  ]
535
551
  },
552
+ {
553
+ "name": "code-block",
554
+ "type": "registry:ui",
555
+ "title": "Code-block",
556
+ "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
+ "dependencies": [
558
+ "class-variance-authority",
559
+ "dowel-ui@^0.26.0"
560
+ ],
561
+ "registryDependencies": [
562
+ "https://lacodda.github.io/dowel/r/copy-button.json"
563
+ ],
564
+ "files": [
565
+ {
566
+ "path": "ui/code-block.tsx",
567
+ "target": "@ui/code-block.tsx",
568
+ "type": "registry:ui",
569
+ "content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { CopyButton } from './copy-button'\n\n/*\n * Code, shown inside a product.\n *\n * The frame around a piece of code is the same everywhere and is written again\n * in every product: the scroll that must not wrap, the gutter of line numbers\n * that must not be selectable, the copy button, the caption saying which file\n * this is, and the marking of the lines the reader was sent here to look at.\n * All of that is here.\n *\n * WHAT IS DELIBERATELY NOT HERE IS THE HIGHLIGHTER. A registry component is\n * copied into a product and becomes its file, so whatever this imports becomes\n * that product's dependency for good. Shiki is over a megabyte of grammars\n * before a language is chosen and resolves asynchronously, which would give\n * this component a loading state for text already in memory; Prism is\n * synchronous and mutates globals. Either one decides, on the product's\n * behalf, which languages it ships - which is not a primitive's decision to\n * make.\n *\n * So the split is: this draws, the product colours. `tokens` takes lines of\n * `{ text, kind }` from any highlighter - a mapping is about twenty lines -\n * and the eight kinds are the ones that mean the same thing in every language.\n * Without `tokens` the block renders the plain string and is completely\n * usable, which is the honest default: most code in an interface is four lines\n * of a command, where colour adds nothing.\n *\n * The colours are `--syntax-*`, fixed like the series palette: `if` should not\n * be magenta in one product and cobalt in another. They are measured as TEXT,\n * against the hardest surface a block sits on rather than the typical one -\n * every one clears 4.5:1 in both themes. That is why they are not the series\n * palette, whose light values sink to 1.90:1 at this size.\n */\n\nexport const codeBlockVariants = cva(\n // `group` so the copy button reveals on hover of the whole block rather than\n // only once the pointer has found a button it cannot see; `relative` because\n // a block with no caption has nowhere to put that button but over the code.\n 'group relative overflow-hidden rounded-md border border-line bg-soft font-mono',\n {\n variants: {\n size: {\n sm: 'text-2xs',\n md: 'text-sm',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The eight distinctions worth drawing in every language. A highlighter's own\n * theme names fifty; the rest are one grammar's vocabulary and do not travel,\n * so they fold into the nearest of these. */\nexport type TokenKind =\n | 'keyword'\n | 'string'\n | 'number'\n | 'comment'\n /** An identifier: a variable, a function, a property. */\n | 'name'\n /** A type name, a class, a constructor. */\n | 'type'\n | 'punctuation'\n /** What surrounds the code rather than being it: a decorator, an attribute,\n * a shell prompt, a diff marker. */\n | 'meta'\n\nexport interface CodeToken {\n text: string\n /** Left out for text that takes the ordinary foreground - whitespace,\n * anything the highlighter had no opinion about. */\n kind?: TokenKind\n}\n\n/* A record rather than a template string, because Tailwind reads class names\n * out of the source: `text-syntax-${kind}` compiles to nothing, and the block\n * would render in the default colour with no error anywhere. */\nconst tokenColor: Record<TokenKind, string> = {\n keyword: 'text-syntax-keyword',\n string: 'text-syntax-string',\n number: 'text-syntax-number',\n comment: 'text-syntax-comment',\n name: 'text-syntax-name',\n type: 'text-syntax-type',\n punctuation: 'text-syntax-punctuation',\n meta: 'text-syntax-meta',\n}\n\nexport interface CodeBlockProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onCopy'>,\n VariantProps<typeof codeBlockVariants> {\n /** The code, as it should land on the clipboard. Always required, even when\n * `tokens` is given: what is copied is the text, not a reassembly of the\n * highlighting. */\n code: string\n /** The same code, coloured. Lines of tokens - one array per line, and the\n * newlines are the array boundaries rather than characters in the text.\n * Left out, the block draws `code` in one colour. */\n tokens?: CodeToken[][]\n /** Shown above the code: a file name, a path, a language. It is what brings\n * the header; without one the copy button floats over the code instead,\n * because a strip holding nothing but a button that hides until hover is\n * empty furniture. */\n caption?: string\n /** Numbers down the left. Off by default: they are for code a reader is\n * meant to refer to, and on a two-line command they are furniture. */\n numbered?: boolean\n /** Where the numbering starts, for an excerpt lifted out of a file. */\n firstLine?: number\n /** Lines to mark, in the same numbering the reader sees. */\n highlight?: readonly number[]\n /** Brings the copy button, and names it for a screen reader. Required to\n * have one, and deliberately without a default: a string this component\n * invents is a string the product cannot translate. */\n copyLabel?: string\n /** Announced after a successful copy. Required alongside `copyLabel`. */\n copiedLabel?: string\n /** Told what happened, for a product that wants its own toast. */\n onCopy?: (ok: boolean) => void\n /**\n * Let long lines wrap instead of scrolling.\n *\n * Off by default, and that is the right default for code: a wrapped line\n * lies about where it ends, and indentation is how code is read. It exists\n * for the case where the \"code\" is really a long single-line value - a URL,\n * a token, a stack frame - which scrolls forever and reads no better for it.\n */\n wrap?: boolean\n}\n\nexport function CodeBlock({\n code,\n tokens,\n caption,\n numbered = false,\n firstLine = 1,\n highlight,\n copyLabel,\n copiedLabel,\n onCopy,\n wrap = false,\n size,\n className,\n ...props\n}: CodeBlockProps) {\n /* The trailing newline almost every file ends with would draw an empty final\n * row - and, with numbering on, a number against nothing. It is stripped for\n * drawing only; `code` is what gets copied, unchanged. */\n const lines: CodeToken[][] =\n tokens ?? code.replace(/\\n$/, '').split('\\n').map((text) => [{ text }])\n const marked = new Set(highlight ?? [])\n\n /* Built once. Where it lands is the only thing that differs: inside the\n * header when there is one, and over the code when there is not - which is\n * why the block is `relative`. */\n const copy =\n copyLabel === undefined ? null : (\n <CopyButton\n value={code}\n label={copyLabel}\n copiedLabel={copiedLabel ?? copyLabel}\n onCopy={onCopy}\n className={caption === undefined ? 'absolute right-1.5 top-1.5 z-10 bg-raise' : undefined}\n />\n )\n\n return (\n <div className={cn(codeBlockVariants({ size }), className)} {...props}>\n {/*\n * The header exists for the caption. The copy button goes in it when\n * there is one, and over the code when there is not.\n *\n * The first version drew a header whenever there was EITHER, and a live\n * run showed what that is: a block holding a command, with no caption,\n * got a 34px strip containing one button that is invisible until hover.\n * Empty furniture on the commonest shape there is.\n *\n * Not `<figcaption>`: this is a div, and a caption claiming to be one\n * without a `<figure>` around it is a lie to a screen reader.\n */}\n {caption === undefined ? (\n copy\n ) : (\n <div className=\"flex items-center gap-2 border-b border-line bg-softer px-3 py-1.5\">\n <span className=\"grow truncate text-2xs text-dim\">{caption}</span>\n {copy}\n </div>\n )}\n\n {/*\n * `<pre>` inside the scroller rather than around it, so the horizontal\n * scrollbar belongs to the code and the header stays put above it.\n *\n * `tabIndex={0}` is not decoration: a region that scrolls has to be\n * reachable by the keyboard, or a reader who does not use a pointer\n * cannot see the right-hand end of a long line. It carries a role and a\n * label for the same reason.\n */}\n <pre\n tabIndex={0}\n className={cn(\n 'overflow-x-auto py-2 leading-relaxed',\n // Restored here because a product's shell usually turns selection\n // off - and code that cannot be selected cannot be taken away.\n 'select-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <code className=\"block\">\n {lines.map((line, index) => {\n const number = firstLine + index\n return (\n <span\n key={number}\n className={cn(\n 'flex px-3',\n /* A left rule as well as a tint: a marked line that says so\n * only by a wash of accent is a line nobody notices, and one\n * that a reader who does not see the hue never notices at\n * all. */\n marked.has(number) &&\n 'border-l-2 border-accent bg-accent-soft pl-[calc(0.75rem-2px)]',\n )}\n >\n {numbered && (\n /*\n * `user-select: none` is the whole reason the numbers are\n * drawn here rather than in a counter or a background: a\n * reader who selects the block to copy it must not get \"1\"\n * welded to the front of every line. That is the defect this\n * gutter exists to avoid, and it is invisible until someone\n * pastes.\n */\n <span\n className=\"mr-3 shrink-0 select-none text-right tabular-nums text-faint\"\n style={{ width: `${String(firstLine + lines.length - 1).length}ch` }}\n aria-hidden\n >\n {number}\n </span>\n )}\n {/* `break-words` alongside the wrapping, not instead of it.\n * `pre-wrap` breaks at spaces, and the case `wrap` exists for\n * - a URL, a token, a stack frame - has none: a live run\n * showed a JWT sitting 320px outside a block that had asked\n * to wrap. Only `overflow-wrap` breaks inside a word. */}\n <span\n className={cn(\n 'min-w-0',\n wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre',\n )}\n >\n {/* A line with nothing on it still needs its height, or a\n * blank line between two paragraphs of code closes up and\n * the numbering drifts away from the file. */}\n {line.length === 0 ? (\n '\\n'\n ) : (\n line.map((token, at) => (\n <span\n key={at}\n className={token.kind === undefined ? undefined : tokenColor[token.kind]}\n >\n {token.text}\n </span>\n ))\n )}\n </span>\n </span>\n )\n })}\n </code>\n </pre>\n </div>\n )\n}\n"
570
+ }
571
+ ]
572
+ },
536
573
  {
537
574
  "name": "color-field",
538
575
  "type": "registry:ui",
539
576
  "title": "Color-field",
540
577
  "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.",
541
578
  "dependencies": [
542
- "dowel-ui@^0.24.0"
579
+ "dowel-ui@^0.26.0"
543
580
  ],
544
581
  "registryDependencies": [
545
582
  "https://lacodda.github.io/dowel/r/input.json"
@@ -553,6 +590,24 @@
553
590
  }
554
591
  ]
555
592
  },
593
+ {
594
+ "name": "column-resize-handle",
595
+ "type": "registry:ui",
596
+ "title": "Column-resize-handle",
597
+ "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
+ "dependencies": [
599
+ "dowel-ui@^0.26.0"
600
+ ],
601
+ "registryDependencies": [],
602
+ "files": [
603
+ {
604
+ "path": "ui/column-resize-handle.tsx",
605
+ "target": "@ui/column-resize-handle.tsx",
606
+ "type": "registry:ui",
607
+ "content": "import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * The strip on a header cell's right edge that a column is dragged wider or narrower by.\n *\n * The handle, and the hook that keeps the widths it produces. Pointer events\n * rather than HTML5 drag-and-drop: a desktop shell that takes file drops for\n * itself never lets a `dragstart` reach the page, so the native API is a\n * handle that does nothing there; pointer capture on the handle also keeps\n * the drag alive when the pointer runs ahead of the cell, which at any speed\n * above a crawl it does.\n *\n * The starting width is measured from the cell rather than taken as a prop:\n * on pointerdown the handle reads its parent's box, and every move reports\n * that width plus the distance travelled. So the handle has no `width` to be\n * handed and cannot disagree with what is on screen. Double-click is the\n * reset, because a handle two pixels wide has no room for a second control.\n *\n * The widths themselves are the hook's, and there are two maps in it rather\n * than one. A table with any hand-set width has to switch to\n * `table-layout: fixed`, and under fixed layout every column needs a width\n * or the browser shares the free space equally between those without one -\n * which is how a date column ends up as wide as the title. So beside the\n * widths that were dragged, the hook keeps the widths every other column\n * measured the moment the first drag began, and `widthOf` answers from one\n * or the other. The caller draws one `<col>` per column from it and leaves\n * the one column meant to take the remaining space without a width.\n */\n\nexport interface ColumnResizeHandleProps {\n /** Names the column for assistive technology: \"Resize the Tier column\". */\n label: string\n /** Shown on hover: how to drag and how to reset. */\n hint?: string\n /** The width while dragging, and once more with `done` when the pointer is\n * let go - the moment to persist. */\n onResize: (width: number, done: boolean) => void\n /** Double-click: the column goes back to its natural width. */\n onReset: () => void\n /** Called on pointerdown, before the first `onResize` - the moment for the\n * caller to measure what every column is before the layout goes fixed. */\n onStart?: () => void\n /** Narrower than this and the column is a stripe with nothing in it. */\n minWidth?: number\n className?: string\n}\n\nexport function ColumnResizeHandle({\n label,\n hint,\n onResize,\n onReset,\n onStart,\n minWidth = 56,\n className,\n}: ColumnResizeHandleProps) {\n // Where the drag began, in both senses: the pointer's x and the cell's width.\n const origin = useRef<{ x: number; width: number } | null>(null)\n const [dragging, setDragging] = useState(false)\n\n const widthAt = (event: ReactPointerEvent<HTMLElement>): number | null => {\n const from = origin.current\n if (from === null) return null\n return Math.max(minWidth, Math.round(from.width + event.clientX - from.x))\n }\n\n const begin = (event: ReactPointerEvent<HTMLElement>) => {\n // The primary button only: a right-click on the edge is the context\n // menu's, and a middle one is nobody's.\n if (event.button !== 0) return\n const cell = event.currentTarget.parentElement\n if (cell === null) return\n event.preventDefault()\n event.stopPropagation()\n onStart?.()\n origin.current = { x: event.clientX, width: cell.getBoundingClientRect().width }\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(true)\n }\n\n const move = (event: ReactPointerEvent<HTMLElement>) => {\n const width = widthAt(event)\n if (width !== null) onResize(width, false)\n }\n\n const end = (event: ReactPointerEvent<HTMLElement>) => {\n const width = widthAt(event)\n origin.current = null\n setDragging(false)\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n if (width !== null) onResize(width, true)\n }\n\n return (\n // A span, not a button: a button in a header cell would be one more tab\n // stop per column for something a keyboard cannot usefully drive. The\n // separator role says what it is; the label says which column.\n <span\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n title={hint}\n data-dragging={dragging ? '' : undefined}\n onPointerDown={begin}\n onPointerMove={move}\n onPointerUp={end}\n onPointerCancel={end}\n onDoubleClick={(event) => {\n event.stopPropagation()\n onReset()\n }}\n // The click after the drag must not reach the header: on a sortable\n // column it would flip the sort every time a width was set.\n onClick={(event) => event.stopPropagation()}\n className={cn(\n // Wider to hit than to see: the visible line is the inner pixel, the\n // grab zone is the whole strip. `touch-action: none` is what lets a\n // touch drag the column instead of scrolling the table.\n 'absolute inset-y-0 right-0 z-10 w-2 cursor-col-resize touch-none select-none',\n 'after:absolute after:inset-y-1.5 after:right-0.5 after:w-px after:bg-line after:transition-colors',\n 'hover:after:bg-accent data-[dragging]:after:bg-accent',\n className,\n )}\n />\n )\n}\n\n/** The widths of the columns the hook is asked about, by column id. */\nexport type Widths<K extends string> = Partial<Record<K, number>>\n\n/** What the hook is given. */\nexport interface ColumnWidthsOptions<K extends string> {\n /** What was dragged before - from storage, or nothing. A function is\n * called once, like `useState`'s. */\n initial: Widths<K> | (() => (Widths<K>))\n /** Told the hand-set widths whenever one settles: persist them here. */\n onChange?: (widths: Widths<K>) => void\n /** The width of a column that was not on screen when the others were\n * measured - one turned on after the first drag. */\n fallback?: (id: K) => number\n minWidth?: number\n}\n\nexport interface ColumnWidths<K extends string> {\n /** True while any width is hand-set: the table is in fixed layout. */\n sized: boolean\n /** The widths that were dragged. */\n hand: Widths<K>\n /** Whether this column's width is the person's rather than measured. */\n isHandSized: (id: K) => boolean\n /** A width for the `<col>`: dragged, else measured, else the fallback. */\n widthOf: (id: K) => number | undefined\n /** A width from the handle; `done` on the last one persists. */\n resize: (id: K, width: number, done?: boolean) => void\n /** Back to the natural width; with nothing else hand-set, back to auto layout. */\n reset: (id: K) => void\n /** The natural widths, taken once before the layout goes fixed. Ignored\n * once it has, because what is measured then is the fixed width. */\n measure: (cells: Iterable<[K, number]>) => void\n}\n\nexport function useColumnWidths<K extends string>({\n initial,\n onChange,\n fallback,\n minWidth = 56,\n}: ColumnWidthsOptions<K>): ColumnWidths<K> {\n const [hand, setHand] = useState<Widths<K>>(initial)\n const [natural, setNatural] = useState<Widths<K>>({})\n // A mirror the callbacks read, so a resize that settles in the same tick as\n // its last move persists the value that was just set and not the one from\n // the render before.\n const held = useRef(hand)\n const sized = Object.keys(hand).length > 0\n\n const commit = useCallback(\n (next: Widths<K>, persist: boolean) => {\n held.current = next\n setHand(next)\n if (persist) onChange?.(next)\n },\n [onChange],\n )\n\n const resize = useCallback(\n (id: K, width: number, done = false) => {\n commit({ ...held.current, [id]: Math.max(minWidth, Math.round(width)) }, done)\n },\n [commit, minWidth],\n )\n\n const reset = useCallback(\n (id: K) => {\n const rest: Widths<K> = { ...held.current }\n delete rest[id]\n commit(rest, true)\n // With nothing hand-set the layout goes back to auto, and the next drag\n // measures afresh - the natural widths may have changed with the data.\n if (Object.keys(rest).length === 0) setNatural({})\n },\n [commit],\n )\n\n const measure = useCallback((cells: Iterable<[K, number]>) => {\n if (Object.keys(held.current).length > 0) return\n const measured: Widths<K> = {}\n for (const [id, width] of cells) measured[id] = Math.round(width)\n setNatural(measured)\n }, [])\n\n return {\n sized,\n hand,\n isHandSized: (id) => hand[id] !== undefined,\n widthOf: (id) => hand[id] ?? natural[id] ?? fallback?.(id),\n resize,\n reset,\n measure,\n }\n}\n\n/** The widths of a header row's cells, read off the screen.\n *\n * Each cell names its column in `data-column`; a cell without one - a\n * checkbox column, a row menu - is not a column the hook is asked about and\n * is skipped. Border-box widths, because that is what a `<col>` sets. */\nexport function measureColumns<K extends string>(row: HTMLElement, attribute = 'data-column'): [K, number][] {\n const measured: [K, number][] = []\n for (const cell of row.querySelectorAll<HTMLElement>(`[${attribute}]`)) {\n const id = cell.getAttribute(attribute)\n if (id !== null) measured.push([id as K, cell.getBoundingClientRect().width])\n }\n return measured\n}\n"
608
+ }
609
+ ]
610
+ },
556
611
  {
557
612
  "name": "combobox",
558
613
  "type": "registry:ui",
@@ -561,7 +616,7 @@
561
616
  "dependencies": [
562
617
  "@base-ui/react",
563
618
  "class-variance-authority",
564
- "dowel-ui@^0.24.0"
619
+ "dowel-ui@^0.26.0"
565
620
  ],
566
621
  "registryDependencies": [
567
622
  "https://lacodda.github.io/dowel/r/input.json",
@@ -584,7 +639,7 @@
584
639
  "dependencies": [
585
640
  "@base-ui/react",
586
641
  "class-variance-authority",
587
- "dowel-ui@^0.24.0"
642
+ "dowel-ui@^0.26.0"
588
643
  ],
589
644
  "registryDependencies": [
590
645
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -607,7 +662,7 @@
607
662
  "dependencies": [
608
663
  "@base-ui/react",
609
664
  "class-variance-authority",
610
- "dowel-ui@^0.24.0"
665
+ "dowel-ui@^0.26.0"
611
666
  ],
612
667
  "registryDependencies": [],
613
668
  "files": [
@@ -626,7 +681,7 @@
626
681
  "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>`.",
627
682
  "dependencies": [
628
683
  "@base-ui/react",
629
- "dowel-ui@^0.24.0"
684
+ "dowel-ui@^0.26.0"
630
685
  ],
631
686
  "registryDependencies": [
632
687
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -640,13 +695,31 @@
640
695
  }
641
696
  ]
642
697
  },
698
+ {
699
+ "name": "copy-button",
700
+ "type": "registry:ui",
701
+ "title": "Copy-button",
702
+ "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
+ "dependencies": [
704
+ "dowel-ui@^0.26.0"
705
+ ],
706
+ "registryDependencies": [],
707
+ "files": [
708
+ {
709
+ "path": "ui/copy-button.tsx",
710
+ "target": "@ui/copy-button.tsx",
711
+ "type": "registry:ui",
712
+ "content": "import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * The copy affordance in the corner of a block.\n *\n * Whatever a product shows in a panel - code, a payload, a log, one side of a\n * comparison - somebody eventually wants to take it away, and the button that\n * lets them is written again every time with the same three things missed.\n *\n * **It confirms only after the clipboard does.** The write can be refused:\n * it needs a secure context and, in some browsers, a permission. A tick\n * drawn on click is a lie in exactly the case the reader most needs the\n * truth.\n *\n * **It says so as well as showing it.** A tick that appears silently tells a\n * sighted reader it worked and tells nobody else. The live region is the\n * part that actually reports.\n *\n * **It stays reachable without a pointer.** Revealed on hover, which is\n * right - a permanent button in the corner of every block is clutter - and\n * on its own that makes it unreachable by keyboard. It is visible whenever\n * it has focus too, and that pairing is the whole trick.\n *\n * Copyable is the other shape of this, and the two are not interchangeable:\n * that one is a value sitting in a sentence - inline, truncating, showing the\n * text it copies - and this one is a control beside content already on screen,\n * so it carries an icon and no words.\n */\n\nexport interface CopyButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onCopy'> {\n /** What lands on the clipboard. */\n value: string\n /** What the button is called, for a screen reader. Required, and\n * deliberately without a default: a string this component invents is a\n * string the product cannot translate, and it would ship in English to\n * every reader who does not read English. */\n label: string\n /** What is announced after a successful copy. Required for the same\n * reason. */\n copiedLabel: string\n /** Told what happened, for a product that wants its own toast. `false` means\n * the clipboard refused. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function CopyButton({\n value,\n label,\n copiedLabel,\n onCopy,\n className,\n ...props\n}: CopyButtonProps) {\n const [copied, setCopied] = useState(false)\n const timer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // A component that sets state on a timer has to stop when it goes away, or\n // it wakes up in a tree that no longer exists.\n useEffect(() => () => clearTimeout(timer.current), [])\n\n const copy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(value)\n setCopied(true)\n onCopy?.(true)\n clearTimeout(timer.current)\n timer.current = setTimeout(() => setCopied(false), 1600)\n } catch {\n onCopy?.(false)\n }\n }, [value, onCopy])\n\n return (\n <>\n <button\n type=\"button\"\n onClick={copy}\n aria-label={copied ? copiedLabel : label}\n className={cn(\n 'shrink-0 rounded-sm p-1 text-faint transition-colors',\n 'hover:bg-soft hover:text-text',\n /* `group-hover` rather than a hover of its own: the button is in the\n * corner of a block, and it has to appear when the pointer is\n * anywhere over that block rather than only once it has found the\n * button. The container carries `group`. */\n 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n copied && 'text-good opacity-100',\n className,\n )}\n {...props}\n >\n {copied ? <Tick /> : <Clipboard />}\n </button>\n\n {/* The drawn tick is invisible to a screen reader; this is the part that\n * reports the copy. Outside the button, because its content changes and\n * a live region inside a labelled control is announced twice. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {copied ? copiedLabel : ''}\n </span>\n </>\n )\n}\n\nfunction Clipboard() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <rect x=\"5.5\" y=\"2.5\" width=\"8\" height=\"10\" rx=\"1.5\" />\n <path d=\"M10.5 2.5v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h.5\" />\n </svg>\n )\n}\n\nfunction Tick() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M3 8.5l3.5 3.5L13 5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n"
713
+ }
714
+ ]
715
+ },
643
716
  {
644
717
  "name": "copyable",
645
718
  "type": "registry:ui",
646
719
  "title": "Copyable",
647
720
  "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.",
648
721
  "dependencies": [
649
- "dowel-ui@^0.24.0"
722
+ "dowel-ui@^0.26.0"
650
723
  ],
651
724
  "registryDependencies": [],
652
725
  "files": [
@@ -664,7 +737,7 @@
664
737
  "title": "Date-picker",
665
738
  "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.",
666
739
  "dependencies": [
667
- "dowel-ui@^0.24.0"
740
+ "dowel-ui@^0.26.0"
668
741
  ],
669
742
  "registryDependencies": [
670
743
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -687,7 +760,7 @@
687
760
  "title": "Date-range-picker",
688
761
  "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.",
689
762
  "dependencies": [
690
- "dowel-ui@^0.24.0"
763
+ "dowel-ui@^0.26.0"
691
764
  ],
692
765
  "registryDependencies": [
693
766
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -712,7 +785,7 @@
712
785
  "dependencies": [
713
786
  "@base-ui/react",
714
787
  "class-variance-authority",
715
- "dowel-ui@^0.24.0"
788
+ "dowel-ui@^0.26.0"
716
789
  ],
717
790
  "registryDependencies": [],
718
791
  "files": [
@@ -724,6 +797,44 @@
724
797
  }
725
798
  ]
726
799
  },
800
+ {
801
+ "name": "diff-lines",
802
+ "type": "registry:ui",
803
+ "title": "Diff-lines",
804
+ "description": "Split out like `line-scale` and `table-sort`: a product that wants to know how much moved between two drafts - to put a number in a list, to decide whether to offer the comparison at all - should not have to render a component to find out.",
805
+ "dependencies": [],
806
+ "registryDependencies": [],
807
+ "files": [
808
+ {
809
+ "path": "ui/diff-lines.tsx",
810
+ "target": "@ui/diff-lines.tsx",
811
+ "type": "registry:ui",
812
+ "content": "/*\n * The comparison behind DiffView, with no React in it.\n *\n * Split out like `line-scale` and `table-sort`: a product that wants to know\n * how much moved between two drafts - to put a number in a list, to decide\n * whether to offer the comparison at all - should not have to render a\n * component to find out.\n *\n * Lines rather than words, and that is a choice about the subject. The text\n * being compared here is prose someone wrote and revised - a draft, a note, a\n * configuration file - and prose is revised BY THE LINE. A word-level diff of\n * a rewritten paragraph is confetti: technically accurate, and it answers a\n * question nobody asked. Where the subject really is word-level - a title, a\n * single sentence - a product compares the two strings itself.\n *\n * Plain longest-common-subsequence. The bodies are a page or two, so the exact\n * O(n·m) answer costs nothing and there is no reason to reach for a heuristic.\n *\n * Taken from kilna, which had it first, with the arithmetic unchanged and the\n * pairing added: `rows` is the part the donor did not have, and its absence is\n * what made the donor's two columns drift out of step.\n */\n\n/** One step through the comparison. */\nexport type Change =\n | { kind: 'same'; text: string }\n | { kind: 'added'; text: string }\n | { kind: 'removed'; text: string }\n\n/**\n * One row of a side-by-side comparison: what stands on each side of it.\n *\n * `null` is a side with nothing there - the gap opposite an inserted line -\n * and it is deliberately not an empty string. An empty string is a line\n * somebody wrote that happens to have no characters on it, and a comparison\n * that cannot tell those apart draws a blank line as a deletion.\n */\nexport interface DiffRow {\n before: string | null\n after: string | null\n kind: Change['kind']\n /** Line numbers in each text, 1-based, for a gutter. `null` on the side that\n * has nothing. */\n beforeLine: number | null\n afterLine: number | null\n}\n\n/** How many lines each side may have before the comparison gives up. A table\n * of 2000×2000 is four million cells; past that the honest answer is \"too long\n * to compare\", not a frozen window. */\nexport const LIMIT = 2000\n\nexport function diffLines(before: string, after: string): Change[] {\n const a = before.split('\\n')\n const b = after.split('\\n')\n\n if (a.length > LIMIT || b.length > LIMIT) {\n return [\n { kind: 'removed', text: before },\n { kind: 'added', text: after },\n ]\n }\n\n // lcs[i][j] - the length of the longest common subsequence of a[i..] and\n // b[j..]. Filled backwards so the walk forwards can be greedy.\n const lcs: number[][] = Array.from({ length: a.length + 1 }, () =>\n Array.from({ length: b.length + 1 }, () => 0),\n )\n\n for (let i = a.length - 1; i >= 0; i -= 1) {\n for (let j = b.length - 1; j >= 0; j -= 1) {\n lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)\n }\n }\n\n const changes: Change[] = []\n let i = 0\n let j = 0\n\n while (i < a.length && j < b.length) {\n if (a[i] === b[j]) {\n changes.push({ kind: 'same', text: a[i]! })\n i += 1\n j += 1\n } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n } else {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n }\n\n while (i < a.length) {\n changes.push({ kind: 'removed', text: a[i]! })\n i += 1\n }\n while (j < b.length) {\n changes.push({ kind: 'added', text: b[j]! })\n j += 1\n }\n\n return changes\n}\n\n/**\n * The changes as rows of two columns - the thing that makes side-by-side mean\n * anything.\n *\n * The obvious way to draw two columns is to filter the change list twice: keep\n * everything that is not `added` on the left, everything that is not `removed`\n * on the right. Both columns come out individually correct and they stop\n * lining up at the first insertion, because from there on they hold different\n * numbers of rows. The reader then compares line 4 against line 3 for the rest\n * of the screen, and nothing about the drawing looks wrong - which is why the\n * defect survived in the product this was taken from.\n *\n * Pairing instead makes the alignment structural: a row is one object with two\n * sides, so the columns are the same height by construction rather than by two\n * filters happening to agree.\n *\n * A removal immediately followed by an insertion is paired into ONE row rather\n * than two. That is the common shape of an edit - a line was rewritten - and\n * showing the old and the new opposite each other is the whole point of the\n * comparison. Left as separate rows, a rewritten line reads as a deletion\n * followed by an unrelated addition, with a gap opposite each.\n */\nexport function rows(changes: readonly Change[]): DiffRow[] {\n const out: DiffRow[] = []\n let beforeLine = 1\n let afterLine = 1\n\n for (let at = 0; at < changes.length; at += 1) {\n const change = changes[at]!\n\n if (change.kind === 'same') {\n out.push({\n before: change.text,\n after: change.text,\n kind: 'same',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n continue\n }\n\n if (change.kind === 'removed') {\n // A removal with an insertion right behind it is a rewrite: pair them,\n // so the reader sees what the line became rather than two separate\n // events with a gap opposite each.\n const next = changes[at + 1]\n if (next?.kind === 'added') {\n out.push({\n before: change.text,\n after: next.text,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: afterLine++,\n })\n at += 1\n continue\n }\n out.push({\n before: change.text,\n after: null,\n kind: 'removed',\n beforeLine: beforeLine++,\n afterLine: null,\n })\n continue\n }\n\n out.push({\n before: null,\n after: change.text,\n kind: 'added',\n beforeLine: null,\n afterLine: afterLine++,\n })\n }\n\n return out\n}\n\n/** How much moved, for the one line above a comparison - or for a list that\n * wants to say \"12 lines changed\" without drawing anything. */\nexport function countChanges(changes: readonly Change[]): { added: number; removed: number } {\n let added = 0\n let removed = 0\n for (const change of changes) {\n if (change.kind === 'added') added += 1\n else if (change.kind === 'removed') removed += 1\n }\n return { added, removed }\n}\n"
813
+ }
814
+ ]
815
+ },
816
+ {
817
+ "name": "diff-view",
818
+ "type": "registry:ui",
819
+ "title": "Diff-view",
820
+ "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
+ "dependencies": [
822
+ "class-variance-authority",
823
+ "dowel-ui@^0.26.0"
824
+ ],
825
+ "registryDependencies": [
826
+ "https://lacodda.github.io/dowel/r/copy-button.json",
827
+ "https://lacodda.github.io/dowel/r/diff-lines.json"
828
+ ],
829
+ "files": [
830
+ {
831
+ "path": "ui/diff-view.tsx",
832
+ "target": "@ui/diff-view.tsx",
833
+ "type": "registry:ui",
834
+ "content": "import { useMemo, type HTMLAttributes, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { CopyButton } from './copy-button'\nimport { countChanges, diffLines, rows, type DiffRow } from './diff-lines'\n\n/*\n * Two drafts of the same text, with what moved between them shown.\n *\n * The question this answers is \"how did this read before, and how does it read\n * now\" - a version against the one before it, a proposal against what is\n * there, a file against what is on disk. Not a code review: there is no\n * staging, no comment, nothing to accept. It is for looking.\n *\n * SIDE BY SIDE, AND THE ALIGNMENT IS THE WHOLE THING. The obvious way to draw\n * two columns is to filter the change list twice - keep what is not `added` on\n * the left, what is not `removed` on the right - and it is what the product\n * this came from did. Both columns come out individually correct, and they\n * stop lining up at the first insertion: from there the reader is comparing\n * line 4 against line 3, with nothing looking wrong. `rows` in `diff-lines`\n * pairs the changes instead, so the two sides are one list and cannot drift.\n *\n * The alignment has a second half, in the drawing rather than the data: ONE\n * scrolling region holds both columns, and a row is a single grid row spanning\n * them. Two scrollers - the shape this was first written with - come apart the\n * moment a reader touches one of them, which undoes the pairing at the point\n * it matters most. It also means a row is as tall as its taller side, so a\n * wrapped line on the left keeps its partner beside it instead of pushing the\n * two texts out of step.\n *\n * Below a certain width the pair stacks - the \"after\" line under the \"before\"\n * one - because two columns each too narrow to hold a line of text answer\n * nothing: every line wraps into three and the comparison is worse than one\n * column would have been. The breakpoint is on the component rather than the\n * viewport (`@container`), since a diff in a side panel is narrow on a wide\n * screen.\n *\n * STACKS, not hides. This drew `hidden @3xl:flex` on the after side for a\n * while, under a comment that said \"stacked\" - so a narrow reader saw a line\n * marked `~` as rewritten and nothing to compare it with, on a screen that\n * looked finished. Every test passed: they count cells in the DOM, and jsdom\n * does not resolve a container query. Half a comparison is worse than none.\n *\n * Colour is never the message. A changed line carries a marker glyph in the\n * gutter - `+`, `-`, `~` - so the comparison reads without hue, in a\n * screenshot, and for the eighth of men who would otherwise see two tinted\n * greys.\n */\n\nexport const diffViewVariants = cva('@container flex flex-col gap-2 text-sm', {\n variants: {\n size: {\n sm: '[--diff-max:16rem]',\n md: '[--diff-max:28rem]',\n /** No ceiling: the comparison is as tall as it is, and the page scrolls.\n * For a diff that IS the screen rather than sitting on one. */\n full: '[--diff-max:none]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport interface DiffViewProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onCopy'>,\n VariantProps<typeof diffViewVariants> {\n before: string\n after: string\n /** What each side is called - a version name, a date, \"on disk\". */\n beforeLabel: string\n afterLabel: string\n /** The line above the comparison: how much moved. The component counts, the\n * product says it in words - a count is a plural, and a plural belongs to a\n * language the component does not know. Given the two numbers, and left out\n * entirely when there is nothing to say. */\n summary?: (counts: { added: number; removed: number }) => ReactNode\n /** Numbers down each side. On by default: a comparison is usually read in\n * order to go and change something, and the number is how the reader finds\n * the place. */\n numbered?: boolean\n /**\n * Brings a copy button to each side's header, and names it.\n *\n * A function of the side's label rather than a string, because the two\n * buttons need distinguishable names - \"Copy\" twice on one screen tells a\n * reader using them which is which only by where they are, which is what a\n * label exists to avoid. Sticking the two together here (`${copy}: ${side}`)\n * would invent a phrase in a grammar this component does not know.\n */\n copyLabel?: (side: string) => string\n /** Announced after a successful copy. Required alongside `copyLabel`. */\n copiedLabel?: string\n /** Told what happened, for a product that wants its own toast. */\n onCopy?: (ok: boolean) => void\n}\n\nexport function DiffView({\n before,\n after,\n beforeLabel,\n afterLabel,\n summary,\n numbered = true,\n copyLabel,\n copiedLabel = '',\n onCopy,\n size,\n className,\n ...props\n}: DiffViewProps) {\n const changes = useMemo(() => diffLines(before, after), [before, after])\n const paired = useMemo(() => rows(changes), [changes])\n const counts = useMemo(() => countChanges(changes), [changes])\n\n /* Wide enough for the largest number either side will show. Sized from the\n * row count rather than from each column's own last number, so the two\n * gutters are the same width and the texts start at the same offset. */\n const width = String(paired.length).length\n\n return (\n <div className={cn(diffViewVariants({ size }), className)} {...props}>\n {summary !== undefined && <p className=\"text-xs text-dim\">{summary(counts)}</p>}\n\n <div className=\"overflow-hidden rounded-lg border border-line\">\n {/*\n * The headers sit outside the scroller, in the same two tracks, so\n * they stay put while the text moves under them.\n *\n * In one column they sit side by side instead of stacking, with an\n * arrow between them: stacked, they would be two labels separated by\n * the whole of the left-hand text, which labels nothing. Both are\n * always drawn - a comparison that names one of its two sides is one\n * the reader has to guess at.\n */}\n <div className=\"group flex items-center gap-2 border-b border-line bg-softer px-3 py-1 text-2xs font-medium text-dim @3xl:grid @3xl:grid-cols-2 @3xl:gap-0 @3xl:px-0 @3xl:py-0\">\n <span className=\"flex min-w-0 items-center gap-2 @3xl:grow @3xl:px-3 @3xl:py-1\">\n <span className=\"truncate @3xl:grow\">{beforeLabel}</span>\n {copyLabel !== undefined && (\n <CopyButton\n value={before}\n label={copyLabel(beforeLabel)}\n copiedLabel={copiedLabel}\n onCopy={onCopy}\n />\n )}\n </span>\n {/* Only while the two labels share a line. In two columns the tracks\n * say which is which. */}\n <span className=\"shrink-0 text-faint @3xl:hidden\" aria-hidden>\n {'\\u2192'}\n </span>\n <span className=\"flex min-w-0 items-center gap-2 @3xl:border-l @3xl:border-line @3xl:px-3 @3xl:py-1\">\n <span className=\"truncate @3xl:grow\">{afterLabel}</span>\n {copyLabel !== undefined && (\n <CopyButton\n value={after}\n label={copyLabel(afterLabel)}\n copiedLabel={copiedLabel}\n onCopy={onCopy}\n />\n )}\n </span>\n </div>\n\n <div\n /* The one scrolling region. Focusable because it scrolls: a region a\n * pointer can reach and a keyboard cannot is the usual way a long\n * diff hides its end. */\n tabIndex={0}\n className={cn(\n 'max-h-[var(--diff-max)] overflow-auto py-1 font-mono text-xs leading-relaxed',\n // A desktop shell turns selection off; a comparison is read in\n // order to copy something out of it.\n 'select-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n )}\n >\n <div className=\"grid grid-cols-1 @3xl:grid-cols-2\">\n {paired.map((row, index) => (\n // Lines repeat and reorder, so the text is not an identity; the\n // list is rebuilt whole whenever either side changes.\n <Row key={index} row={row} numbered={numbered} width={width} />\n ))}\n </div>\n </div>\n </div>\n </div>\n )\n}\n\n/* The glyph in the gutter, per side. A deletion is only a deletion on the left\n * - on the right that same row is a gap - so the marker depends on which\n * column is being drawn, not on the change alone. `~` for a rewritten line,\n * which is one event with a side each. */\nexport function marker(row: DiffRow, side: 'before' | 'after'): string {\n const text = side === 'before' ? row.before : row.after\n if (row.kind === 'same' || text === null) return ' '\n if (row.kind === 'removed' && row.after !== null) return '~'\n return side === 'before' ? '-' : '+'\n}\n\n/*\n * One row, as its two cells.\n *\n * They are siblings in the grid rather than a wrapper holding both, because a\n * wrapper would become the grid item and the columns would stop being columns.\n * `display: contents` would do it too and is worse: it removes the element\n * from the accessibility tree in several browsers, taking any grouping with\n * it.\n */\nfunction Row({ row, numbered, width }: { row: DiffRow; numbered: boolean; width: number }) {\n return (\n <>\n <Side row={row} side=\"before\" numbered={numbered} width={width} />\n <Side row={row} side=\"after\" numbered={numbered} width={width} />\n </>\n )\n}\n\nfunction Side({\n row,\n side,\n numbered,\n width,\n}: {\n row: DiffRow\n side: 'before' | 'after'\n numbered: boolean\n width: number\n}) {\n const text = side === 'before' ? row.before : row.after\n const line = side === 'before' ? row.beforeLine : row.afterLine\n const changed = row.kind !== 'same' && text !== null\n\n return (\n <div\n className={cn(\n 'flex px-2',\n // The rule between the columns belongs to the right-hand cells, so it\n // runs the full height of the text rather than stopping at the last\n // row of a short column. Gone while stacked, where there is no second\n // column for it to divide.\n /*\n * On the right in two columns; UNDER its partner in one.\n *\n * Never hidden, and that is the whole note. This read `hidden\n * @3xl:flex` for a while, under a comment saying the comparison\n * \"stacks\" - it did not stack, it dropped the after side entirely, so\n * a narrow reader saw a line marked `~` and nothing to compare it\n * with. A screen that looks finished and withholds half the answer is\n * worse than one that admits it has no room.\n *\n * Stacking needs no rule of its own: the cells are already siblings of\n * a grid that is one column until `@3xl`, so they fall under each\n * other by themselves. What the narrow layout does need is the rule\n * BETWEEN the pair, which is a top border there and a left border in\n * two columns.\n */\n side === 'after' && 'border-t border-line @3xl:border-t-0 @3xl:border-l',\n changed && (side === 'before' ? 'bg-bad-soft text-bad' : 'bg-good-soft text-good'),\n )}\n >\n {numbered && (\n // Never part of a selection: a reader copying a column wants the text,\n // not the text with a number welded to the front of every line.\n <span\n className=\"mr-2 shrink-0 select-none text-right tabular-nums text-faint\"\n style={{ width: `${width}ch` }}\n aria-hidden\n >\n {line ?? ''}\n </span>\n )}\n\n {/* The second channel, so the comparison reads without colour.\n * `aria-hidden` because a screen reader is told what changed by the\n * text itself; a spoken \"minus\" before every removed line is noise. */}\n <span className=\"mr-1.5 shrink-0 select-none\" aria-hidden>\n {marker(row, side)}\n </span>\n\n <span className=\"min-w-0 whitespace-pre-wrap break-words\">\n {/* A blank line still needs its height, or a gap opposite an insertion\n * collapses and the two columns come out of step by exactly the thing\n * the pairing prevented. */}\n {text === null || text === '' ? ' ' : text}\n </span>\n </div>\n )\n}\n"
835
+ }
836
+ ]
837
+ },
727
838
  {
728
839
  "name": "drawer",
729
840
  "type": "registry:ui",
@@ -732,7 +843,7 @@
732
843
  "dependencies": [
733
844
  "@base-ui/react",
734
845
  "class-variance-authority",
735
- "dowel-ui@^0.24.0"
846
+ "dowel-ui@^0.26.0"
736
847
  ],
737
848
  "registryDependencies": [],
738
849
  "files": [
@@ -750,7 +861,7 @@
750
861
  "title": "Duration-field",
751
862
  "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.",
752
863
  "dependencies": [
753
- "dowel-ui@^0.24.0"
864
+ "dowel-ui@^0.26.0"
754
865
  ],
755
866
  "registryDependencies": [
756
867
  "https://lacodda.github.io/dowel/r/input.json"
@@ -771,7 +882,7 @@
771
882
  "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.",
772
883
  "dependencies": [
773
884
  "class-variance-authority",
774
- "dowel-ui@^0.24.0"
885
+ "dowel-ui@^0.26.0"
775
886
  ],
776
887
  "registryDependencies": [],
777
888
  "files": [
@@ -809,7 +920,7 @@
809
920
  "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.",
810
921
  "dependencies": [
811
922
  "@base-ui/react",
812
- "dowel-ui@^0.24.0"
923
+ "dowel-ui@^0.26.0"
813
924
  ],
814
925
  "registryDependencies": [],
815
926
  "files": [
@@ -827,7 +938,7 @@
827
938
  "title": "File-drop",
828
939
  "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.",
829
940
  "dependencies": [
830
- "dowel-ui@^0.24.0"
941
+ "dowel-ui@^0.26.0"
831
942
  ],
832
943
  "registryDependencies": [],
833
944
  "files": [
@@ -839,13 +950,34 @@
839
950
  }
840
951
  ]
841
952
  },
953
+ {
954
+ "name": "filter-popover",
955
+ "type": "registry:ui",
956
+ "title": "Filter-popover",
957
+ "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
+ "dependencies": [
959
+ "dowel-ui@^0.26.0"
960
+ ],
961
+ "registryDependencies": [
962
+ "https://lacodda.github.io/dowel/r/button.json",
963
+ "https://lacodda.github.io/dowel/r/popover.json"
964
+ ],
965
+ "files": [
966
+ {
967
+ "path": "ui/filter-popover.tsx",
968
+ "target": "@ui/filter-popover.tsx",
969
+ "type": "registry:ui",
970
+ "content": "import type { ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from './popover'\n\n/*\n * A funnel in a column header that opens a small panel for narrowing by that column.\n *\n * A text box, a handful of checkboxes - with one way to clear it. The shell\n * only: what goes in the panel is the caller's, since a stage is ticked and\n * a title is typed and the popover has no opinion.\n *\n * The funnel is drawn filled while the column's filter holds something, and\n * that is the whole of the state it shows. A column with a filter on it has\n * to say so from the header, or a table narrowed by a funnel opened last\n * week looks like a table with fewer rows in it. Whether the funnel is\n * visible at rest or only on hover is left to the caller's classes: a\n * header with five funnels always showing is a header nobody can read, but\n * hiding the active one would hide the one thing that matters, so the\n * `data-active` attribute is there for a rule to key off.\n *\n * \"Clear\" is inside the panel rather than a second control beside the\n * funnel, because unticking three boxes one by one is the failure this\n * exists to prevent, and a panel is the place a person is already looking.\n */\n\nexport interface FilterPopoverProps {\n /** The heading inside the panel: the column's name. */\n title: string\n /** What the funnel says to assistive technology and on hover. */\n label: string\n /** Whether the column's filter holds anything; fills the funnel. */\n active: boolean\n clearLabel: string\n onClear: () => void\n /** The controls: a text box, checkboxes, whatever narrows this column. */\n children: ReactNode\n /** On the trigger, for showing it on hover or always. */\n className?: string\n align?: 'start' | 'center' | 'end'\n}\n\nexport function FilterPopover({\n title,\n label,\n active,\n clearLabel,\n onClear,\n children,\n className,\n align,\n}: FilterPopoverProps) {\n return (\n <Popover>\n <PopoverTrigger\n aria-label={label}\n title={label}\n data-active={active ? '' : undefined}\n className={cn(\n 'inline-flex size-5 shrink-0 cursor-pointer items-center justify-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 'data-[active]:text-accent data-[popup-open]:text-text',\n className,\n )}\n >\n <svg\n viewBox=\"0 0 16 16\"\n className={cn('size-3', active && 'fill-current')}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.4\"\n strokeLinejoin=\"round\"\n aria-hidden\n >\n <path d=\"M2 3h12l-4.5 5.5V13l-3-1.5V8.5z\" />\n </svg>\n </PopoverTrigger>\n <PopoverPopup size=\"sm\" align={align ?? 'start'} arrow={false} className=\"p-3\">\n <div className=\"flex items-center gap-2\">\n {/* Sentence case rather than the header's uppercase: the panel is\n read, the header is scanned. */}\n <PopoverTitle className=\"normal-case tracking-normal\">{title}</PopoverTitle>\n <Button size=\"sm\" variant=\"ghost\" className=\"ml-auto\" disabled={!active} onClick={onClear}>\n {clearLabel}\n </Button>\n </div>\n <div className=\"mt-2 flex flex-col gap-1.5 text-sm normal-case tracking-normal\">{children}</div>\n </PopoverPopup>\n </Popover>\n )\n}\n"
971
+ }
972
+ ]
973
+ },
842
974
  {
843
975
  "name": "input",
844
976
  "type": "registry:ui",
845
977
  "title": "Input",
846
978
  "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.",
847
979
  "dependencies": [
848
- "dowel-ui@^0.24.0"
980
+ "dowel-ui@^0.26.0"
849
981
  ],
850
982
  "registryDependencies": [],
851
983
  "files": [
@@ -857,13 +989,49 @@
857
989
  }
858
990
  ]
859
991
  },
992
+ {
993
+ "name": "json-rows",
994
+ "type": "registry:ui",
995
+ "title": "Json-rows",
996
+ "description": "The sibling of `tree-rows`, built the same way and for the same reason: a product that wants to count the rows before drawing any of them - to put a viewer inside a VirtualList, to say \"1,204 entries\" - imports this and never the component.",
997
+ "dependencies": [],
998
+ "registryDependencies": [],
999
+ "files": [
1000
+ {
1001
+ "path": "ui/json-rows.tsx",
1002
+ "target": "@ui/json-rows.tsx",
1003
+ "type": "registry:ui",
1004
+ "content": "/*\n * What a JSON value looks like as a list of rows, with no React in it.\n *\n * The sibling of `tree-rows`, built the same way and for the same reason: a\n * product that wants to count the rows before drawing any of them - to put a\n * viewer inside a VirtualList, to say \"1,204 entries\" - imports this and never\n * the component.\n *\n * Flattening is also what makes the keyboard simple. Down is the next row of\n * this list and Up the previous, whatever the nesting; a recursive walk at\n * every keystroke asks the same question and answers it differently at each\n * depth.\n */\n\n/** What a value is, for drawing and for deciding whether it opens.\n *\n * `null` is its own kind rather than an absence: in JSON it is a value\n * somebody wrote, and a viewer that shows it as an empty cell says the key is\n * missing when it is present and null - a distinction that decides bugs. */\nexport type JsonKind = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null'\n\n/** Any value `JSON.parse` can return. */\nexport type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\nexport interface JsonRow {\n /**\n * Where this sits, in JSONPath: `$.items[3].name`.\n *\n * The identity of a row, and deliberately not its index: a row's index\n * changes when a branch above it opens, and anything remembered by index -\n * which rows are open, which is selected - would jump to a different value\n * the moment something above it moved. It is also what a reader copies when\n * they want to point at this value from somewhere else.\n */\n path: string\n /** The key, or the index as written in the path. `null` only for the root. */\n key: string | null\n /** True when the key is an array index rather than an object's name - drawn\n * differently, because `0` as a name and `0` as a position are not the same\n * thing. */\n index: boolean\n kind: JsonKind\n /** The value itself, for a leaf. A branch has none: what it holds is in the\n * rows below it. */\n value?: string | number | boolean | null\n depth: number\n /** How many entries a branch holds, so a closed one can say so without being\n * opened. */\n size?: number\n /** The path of the branch this row sits in, if any. What Left uses to get\n * out of a deep branch in one press. */\n parent?: string\n}\n\nexport function kindOf(value: JsonValue): JsonKind {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'array'\n return typeof value as JsonKind\n}\n\nexport function isBranch(kind: JsonKind): boolean {\n return kind === 'object' || kind === 'array'\n}\n\n/*\n * A key as it appears in a path.\n *\n * Dot notation where the key is an ordinary identifier, brackets otherwise -\n * which is not decoration. `$.user name` is not a path anything can resolve,\n * and a key containing a dot (`$.a.b` for the single key `\"a.b\"`) is a path\n * that resolves to the WRONG value silently. Both are common in real data:\n * configuration files and anything exported from a spreadsheet.\n */\nfunction step(key: string): string {\n return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)\n ? `.${key}`\n : `[${JSON.stringify(key)}]`\n}\n\n/**\n * The rows a value shows, in the order the eye and the keyboard travel.\n *\n * Only what is visible: a closed branch contributes its own row and nothing\n * below it. That is what keeps a viewer of a large document cheap - a\n * thousand-entry array that nobody opened costs one row.\n */\nexport function visibleRows(\n value: JsonValue,\n open: ReadonlySet<string>,\n { root = '$' }: { root?: string } = {},\n): JsonRow[] {\n const rows: JsonRow[] = []\n\n const walk = (\n node: JsonValue,\n path: string,\n key: string | null,\n index: boolean,\n depth: number,\n parent?: string,\n ): void => {\n const kind = kindOf(node)\n\n if (!isBranch(kind)) {\n rows.push({ path, key, index, kind, value: node as string | number | boolean | null, depth, parent })\n return\n }\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n rows.push({ path, key, index, kind, depth, size: entries.length, parent })\n\n if (!open.has(path)) return\n\n for (const [childKey, child] of entries) {\n walk(\n child,\n Array.isArray(node) ? `${path}[${childKey}]` : `${path}${step(childKey)}`,\n childKey,\n Array.isArray(node),\n depth + 1,\n path,\n )\n }\n }\n\n walk(value, root, null, false, 0)\n return rows\n}\n\n/**\n * The paths to open so a document arrives readable.\n *\n * Bounded twice, by depth AND by size, and the second bound is the one that\n * does the work. A depth bound alone reads as sufficient and is not: the cost\n * of opening is measured in ROWS, while depth counts LEVELS, and those track\n * each other only while the branches are small - which is the case nobody\n * needed protecting from.\n *\n * Measured, not reasoned: a payload holding `assets: [1204 entries]` at its\n * top level rendered 1216 rows and 24,000 pixels of scroll on arrival under a\n * depth-2 bound, because the array sits AT depth 2. That is exactly the freeze\n * the bound exists to prevent, produced by the bound itself. The shape that\n * hurts is one huge branch near the surface, and a level count is blindest to\n * precisely that shape.\n *\n * So a branch opens when it is shallow enough AND holds fewer than `size`\n * entries. A branch left shut is not descended through either - what is inside\n * something the reader cannot see does not need deciding about.\n */\nexport function branchPaths(\n value: JsonValue,\n {\n root = '$',\n depth = 2,\n /** How many entries a branch may hold and still open by itself. Twenty is\n * about a screen: enough that a settings file arrives open, few enough\n * that a list of records arrives as a list of records. */\n size = 20,\n }: { root?: string; depth?: number; size?: number } = {},\n): Set<string> {\n const paths = new Set<string>()\n\n const walk = (node: JsonValue, path: string, level: number): void => {\n const kind = kindOf(node)\n if (!isBranch(kind) || level > depth) return\n\n const entries: [string, JsonValue][] = Array.isArray(node)\n ? node.map((item, at) => [String(at), item])\n : Object.entries(node as { [key: string]: JsonValue })\n\n // Too big to open, so it stays shut - and nothing below it is considered:\n // those rows are not going to be drawn either way.\n if (entries.length > size) return\n\n paths.add(path)\n\n for (const [key, child] of entries) {\n walk(child, Array.isArray(node) ? `${path}[${key}]` : `${path}${step(key)}`, level + 1)\n }\n }\n\n walk(value, root, 1)\n return paths\n}\n\n/**\n * What a closed branch says about itself: `{ 4 }` or `[ 1204 ]`.\n *\n * The count rather than a preview of the contents. A preview of the first\n * entries reads as though those are all of them, which is the one thing a\n * closed branch must not imply.\n */\nexport function summarise(row: JsonRow): string {\n if (row.kind === 'array') return `[ ${row.size ?? 0} ]`\n return `{ ${row.size ?? 0} }`\n}\n"
1005
+ }
1006
+ ]
1007
+ },
1008
+ {
1009
+ "name": "json-viewer",
1010
+ "type": "registry:ui",
1011
+ "title": "Json-viewer",
1012
+ "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
+ "dependencies": [
1014
+ "dowel-ui@^0.26.0"
1015
+ ],
1016
+ "registryDependencies": [
1017
+ "https://lacodda.github.io/dowel/r/json-rows.json"
1018
+ ],
1019
+ "files": [
1020
+ {
1021
+ "path": "ui/json-viewer.tsx",
1022
+ "target": "@ui/json-viewer.tsx",
1023
+ "type": "registry:ui",
1024
+ "content": "import { useMemo, useState, type KeyboardEvent } from 'react'\nimport { cn } from 'dowel-ui'\nimport {\n branchPaths,\n isBranch,\n summarise,\n visibleRows,\n type JsonRow,\n type JsonValue,\n} from './json-rows'\n\n/*\n * A JSON value, read rather than parsed by eye.\n *\n * What a product reaches for when it has to show a response, a settings file,\n * a webhook payload - data the reader needs to understand, not edit. The\n * alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`,\n * which is fine for twenty lines and useless for two hundred: nothing folds,\n * nothing is findable, and the shape of the document is somewhere inside the\n * indentation.\n *\n * It is a TREE, and it deliberately does not reuse TreeView. A row here is a\n * key AND a value, and TreeView's row is one `ReactNode` label - pouring JSON\n * into it means the component can no longer colour a value by its type, tell\n * an array index from a name, or offer the two things a reader actually wants\n * (the value at this row, the path to it). What is genuinely shared is the\n * arithmetic of which rows are visible, and that is shared: `json-rows` is the\n * sibling of `tree-rows`, both with no React in them.\n *\n * The keyboard is the ARIA tree pattern, for the reason TreeView states it:\n *\n * **One tab stop, not one per row.** A document of four hundred rows with a\n * `tabIndex` on each is four hundred stops between whatever is above it and\n * whatever is below.\n *\n * **Right and Left do different things depending on where the cursor is.**\n * Right opens a closed branch, steps into an open one, does nothing on a\n * leaf; Left closes an open branch and otherwise jumps to the parent, which\n * is how a reader gets out of a deep branch without walking back up through\n * every sibling.\n *\n * Values are coloured with `--syntax-*`, the same tokens a CodeBlock uses, so\n * a string is the same green in both. Colour is never the only signal: a\n * string is quoted, a branch says how many it holds, and null is the word.\n */\n\nexport interface JsonViewerProps {\n value: JsonValue\n /** Which branches are open, by path. Uncontrolled if omitted, starting with\n * the top two levels open - the shape of an answer rather than the whole\n * document. */\n open?: ReadonlySet<string>\n onOpenChange?: (open: Set<string>) => void\n /** Told which row was activated, with its path and its value. What a product\n * hangs \"copy this\" or \"go to this setting\" on. */\n onActivate?: (row: JsonRow) => void\n /** What a screen reader calls it. Required, and without a default: a string\n * this component invents is a string the product cannot translate. */\n label: string\n /** The path the document starts at, for a viewer showing one field of a\n * larger record. Paths are then written so they still mean something in that\n * record. */\n root?: string\n className?: string\n}\n\nexport function JsonViewer({\n value,\n open: openProp,\n onOpenChange,\n onActivate,\n label,\n root = '$',\n className,\n}: JsonViewerProps) {\n const [openState, setOpenState] = useState(() => branchPaths(value, { root }))\n const open = openProp ?? openState\n\n const rows = useMemo(() => visibleRows(value, open, { root }), [value, open, root])\n\n /* The cursor is a path, not an index. An index would point at a different\n * value the moment a branch above it opened - the cursor would appear to\n * jump on its own. */\n const [cursor, setCursor] = useState<string>(root)\n const at = Math.max(\n 0,\n rows.findIndex((row) => row.path === cursor),\n )\n\n const setOpen = (next: Set<string>) => {\n if (openProp === undefined) setOpenState(next)\n onOpenChange?.(next)\n }\n\n const toggle = (path: string) => {\n const next = new Set(open)\n if (next.has(path)) next.delete(path)\n else next.add(path)\n setOpen(next)\n }\n\n const move = (to: number) => {\n const row = rows[Math.min(Math.max(to, 0), rows.length - 1)]\n if (row !== undefined) setCursor(row.path)\n }\n\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n const row = rows[at]\n if (row === undefined) return\n\n switch (event.key) {\n case 'ArrowDown':\n move(at + 1)\n break\n case 'ArrowUp':\n move(at - 1)\n break\n case 'ArrowRight':\n // Open a closed branch; step into an open one. On a leaf, nothing -\n // rather than falling through to the next row, which would make Right\n // a second Down and lose the reader their place in the nesting.\n if (isBranch(row.kind) && !open.has(row.path)) toggle(row.path)\n else if (isBranch(row.kind)) move(at + 1)\n else return\n break\n case 'ArrowLeft':\n // Close what is open; otherwise leave the branch. The second half is\n // what makes a deep document navigable at all.\n if (isBranch(row.kind) && open.has(row.path)) toggle(row.path)\n else if (row.parent !== undefined) setCursor(row.parent)\n else return\n break\n case 'Home':\n move(0)\n break\n case 'End':\n move(rows.length - 1)\n break\n case 'Enter':\n case ' ':\n if (isBranch(row.kind)) toggle(row.path)\n onActivate?.(row)\n break\n default:\n return\n }\n // Only for a key this component handled: swallowing every keystroke would\n // take Tab and the browser's own shortcuts with it.\n event.preventDefault()\n }\n\n return (\n <div\n role=\"tree\"\n aria-label={label}\n // The one tab stop. The arrows move a cursor inside it - the arrangement\n // a radio group has, and for the same reason.\n tabIndex={0}\n onKeyDown={onKeyDown}\n className={cn(\n 'overflow-auto rounded-md border border-line bg-soft py-1 font-mono text-xs leading-relaxed',\n // A desktop shell turns selection off; this is data someone came to\n // take away.\n 'select-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n >\n {rows.map((row) => {\n const branch = isBranch(row.kind)\n const expanded = branch ? open.has(row.path) : undefined\n\n return (\n <div\n key={row.path}\n role=\"treeitem\"\n aria-level={row.depth + 1}\n aria-expanded={expanded}\n aria-selected={row.path === cursor}\n onClick={() => {\n setCursor(row.path)\n if (branch) toggle(row.path)\n onActivate?.(row)\n }}\n className={cn(\n 'flex cursor-default items-baseline gap-1.5 px-2 py-px',\n 'hover:bg-softer',\n row.path === cursor && 'bg-accent-soft',\n )}\n // Indentation as padding rather than nested elements: a row four\n // levels down is still a sibling of every other row, which is what\n // lets the whole list be windowed.\n style={{ paddingLeft: `${row.depth * 0.9 + 0.5}rem` }}\n >\n {/* The twisty. A fixed-width slot even on a leaf, so the keys of a\n * branch and a leaf at the same level line up - without it the\n * eye reads the indentation wrong. */}\n <span className={cn('w-2 shrink-0 text-faint', !branch && 'invisible')} aria-hidden>\n {expanded === true ? '▾' : '▸'}\n </span>\n\n {row.key !== null && (\n <>\n <span className={row.index ? 'text-faint tabular-nums' : 'text-syntax-name'}>\n {row.index ? row.key : `\"${row.key}\"`}\n </span>\n <span className=\"text-syntax-punctuation\" aria-hidden>\n :\n </span>\n </>\n )}\n\n {branch ? (\n /* A closed branch says how many it holds; an open one says it\n * too, because the count is still the fastest answer to \"how big\n * is this\" once the reader has scrolled past the first entries. */\n <span className=\"text-faint\">{summarise(row)}</span>\n ) : (\n <Leaf row={row} />\n )}\n </div>\n )\n })}\n </div>\n )\n}\n\n/*\n * A value, drawn as what it is.\n *\n * Quotes on a string are not decoration: `\"1\"` and `1` are different values,\n * and a viewer that draws them alike hides the commonest bug in any JSON\n * payload - a number that arrived as a string. Colour says the same thing\n * faster for those who see it; the quotes say it to everyone.\n */\nfunction Leaf({ row }: { row: JsonRow }) {\n if (row.kind === 'string') {\n return (\n <span className=\"min-w-0 break-all text-syntax-string\">{`\"${String(row.value)}\"`}</span>\n )\n }\n if (row.kind === 'number') {\n return <span className=\"text-syntax-number tabular-nums\">{String(row.value)}</span>\n }\n if (row.kind === 'boolean') {\n return <span className=\"text-syntax-keyword\">{String(row.value)}</span>\n }\n // `null` in the same colour as a comment: present, and nothing there. Drawn\n // as the word rather than as an empty cell, which would read as a key with\n // no value at all.\n return <span className=\"text-syntax-comment\">null</span>\n}\n"
1025
+ }
1026
+ ]
1027
+ },
860
1028
  {
861
1029
  "name": "kbd",
862
1030
  "type": "registry:ui",
863
1031
  "title": "Kbd",
864
1032
  "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.",
865
1033
  "dependencies": [
866
- "dowel-ui@^0.24.0"
1034
+ "dowel-ui@^0.26.0"
867
1035
  ],
868
1036
  "registryDependencies": [],
869
1037
  "files": [
@@ -882,7 +1050,7 @@
882
1050
  "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.",
883
1051
  "dependencies": [
884
1052
  "class-variance-authority",
885
- "dowel-ui@^0.24.0"
1053
+ "dowel-ui@^0.26.0"
886
1054
  ],
887
1055
  "registryDependencies": [],
888
1056
  "files": [
@@ -901,7 +1069,7 @@
901
1069
  "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.",
902
1070
  "dependencies": [
903
1071
  "class-variance-authority",
904
- "dowel-ui@^0.24.0"
1072
+ "dowel-ui@^0.26.0"
905
1073
  ],
906
1074
  "registryDependencies": [
907
1075
  "https://lacodda.github.io/dowel/r/line-scale.json"
@@ -931,6 +1099,24 @@
931
1099
  }
932
1100
  ]
933
1101
  },
1102
+ {
1103
+ "name": "marked-text",
1104
+ "type": "registry:ui",
1105
+ "title": "Marked-text",
1106
+ "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
+ "dependencies": [
1108
+ "dowel-ui@^0.26.0"
1109
+ ],
1110
+ "registryDependencies": [],
1111
+ "files": [
1112
+ {
1113
+ "path": "ui/marked-text.tsx",
1114
+ "target": "@ui/marked-text.tsx",
1115
+ "type": "registry:ui",
1116
+ "content": "import {\n useCallback,\n useMemo,\n type ChangeEvent,\n type CSSProperties,\n type ReactNode,\n type Ref,\n type TextareaHTMLAttributes,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Text with marks on it - read, or typed into.\n *\n * A textarea cannot colour a word. The way round it is older than React: draw\n * the same text twice, once as marked-up HTML underneath and once as the\n * textarea on top with its own text transparent, so the caret and the\n * selection are the browser's and the colours are ours. The two have to agree\n * on every metric - font, size, line height, padding, wrapping - or the marks\n * slide off the words they mark. So both take ONE class list, given by the\n * caller, and the textarea adds only what makes it invisible.\n *\n * The mirror is also what sizes the box. It sits in the flow and the textarea\n * is stretched over it, which means the field grows with its text without a\n * `scrollHeight` measurement and never scrolls inside itself - the box around\n * it scrolls, and the marks scroll with the words. A trailing space on the\n * last line keeps a final newline from being a line the mirror forgot.\n *\n * Two kinds of mark: a span of characters (a repeated word, a misspelling)\n * and a whole line (a line that is new since the other version). Lines are\n * drawn as blocks rather than separated by newlines so a line mark can paint\n * the full width; a block per line wraps exactly as the textarea's line does,\n * since it is the same text in the same width with the same font.\n */\n\n/** A span of characters, as offsets into the whole text. */\nexport interface Mark {\n start: number\n end: number\n className?: string\n style?: CSSProperties\n}\n\n/** A whole line. */\nexport interface LineMark {\n /** Zero-based line index. */\n line: number\n className?: string\n}\n\nexport interface MarkedLinesProps {\n text: string\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** Transparent text: the textarea above shows the letters. */\n ghost?: boolean\n}\n\n/** One line cut into runs, each run under at most one mark. */\nfunction runs(line: string, offset: number, marks: readonly Mark[]): ReactNode[] {\n const out: ReactNode[] = []\n let at = 0\n for (const mark of marks) {\n const start = Math.max(mark.start - offset, 0)\n const end = Math.min(mark.end - offset, line.length)\n if (end <= at || start >= line.length) continue\n if (start > at) out.push(line.slice(at, start))\n out.push(\n <mark\n key={offset + start}\n // `<mark>` for the semantics; the browser's yellow is dropped so the\n // caller's class is the colour, in whichever tone the mark means.\n className={cn('rounded-sm bg-transparent text-inherit', mark.className)}\n style={mark.style}\n >\n {line.slice(start, end)}\n </mark>,\n )\n at = end\n }\n if (at < line.length) out.push(line.slice(at))\n return out\n}\n\n/** The marked-up text, line by line. The layer both the readable and the\n * typeable form are made of. */\nexport function MarkedLines({ text, marks = [], lineMarks = [], ghost = false }: MarkedLinesProps) {\n // Each line with where it starts in the text, so a mark given as an offset\n // into the whole can be cut to the line it falls on.\n const lines = useMemo(() => {\n const out: { line: string; start: number }[] = []\n let offset = 0\n for (const line of text.split('\\n')) {\n out.push({ line, start: offset })\n offset += line.length + 1\n }\n return out\n }, [text])\n const byLine = useMemo(() => {\n const map = new Map<number, string | undefined>()\n for (const mark of lineMarks) map.set(mark.line, mark.className)\n return map\n }, [lineMarks])\n\n return (\n <>\n {lines.map(({ line, start }, index) => {\n // Only the marks that touch this line, in order; a text has few marks\n // and few lines, so a filter per line is cheaper than an index.\n const own = marks.filter((mark) => mark.end > start && mark.start < start + line.length)\n return (\n <div\n key={index}\n data-line={index}\n className={cn(\n 'min-h-[1lh] whitespace-pre-wrap [overflow-wrap:anywhere]',\n ghost && 'text-transparent',\n byLine.get(index),\n )}\n >\n {/* A blank line still needs its height, and a trailing line needs\n a character to exist at all. */}\n {line === '' ? ' ' : runs(line, start, own)}\n {index === lines.length - 1 && ' '}\n </div>\n )\n })}\n </>\n )\n}\n\nexport interface MarkedTextProps extends MarkedLinesProps {\n className?: string\n}\n\n/** Marked text to read. */\nexport function MarkedText({ className, ...lines }: MarkedTextProps) {\n return (\n <div className={cn('select-text', className)}>\n <MarkedLines {...lines} />\n </div>\n )\n}\n\nexport interface MarkedTextareaProps\n extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'className'> {\n ref?: Ref<HTMLTextAreaElement>\n value: string\n /** The new value, not the event: the event is the textarea's business. */\n onChange: (value: string) => void\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** The metrics both layers share: font, size, line height, padding. */\n className?: string\n}\n\n/** Marked text to type into. */\nexport function MarkedTextarea({\n value,\n onChange,\n marks,\n lineMarks,\n className,\n ref,\n ...props\n}: MarkedTextareaProps) {\n const change = useCallback(\n (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value),\n [onChange],\n )\n\n return (\n <div className=\"relative\">\n <div aria-hidden data-mirror className={cn('pointer-events-none', className)}>\n <MarkedLines text={value} marks={marks} lineMarks={lineMarks} ghost />\n </div>\n <textarea\n ref={ref}\n value={value}\n onChange={change}\n spellCheck={false}\n className={cn(\n // The same metrics, and nothing that would draw: the letters, the\n // caret and the selection are what this layer is for.\n 'absolute inset-0 h-full w-full resize-none overflow-hidden text-text outline-none',\n '[overflow-wrap:anywhere] whitespace-pre-wrap',\n className,\n // After the caller's classes, on purpose. A background in the\n // metrics is meant for the box and lands on both layers; on this\n // one it would paint over every mark. The mirror keeps it, the\n // field never does.\n 'bg-transparent',\n )}\n {...props}\n />\n </div>\n )\n}\n"
1117
+ }
1118
+ ]
1119
+ },
934
1120
  {
935
1121
  "name": "menu",
936
1122
  "type": "registry:ui",
@@ -939,7 +1125,7 @@
939
1125
  "dependencies": [
940
1126
  "@base-ui/react",
941
1127
  "class-variance-authority",
942
- "dowel-ui@^0.24.0"
1128
+ "dowel-ui@^0.26.0"
943
1129
  ],
944
1130
  "registryDependencies": [],
945
1131
  "files": [
@@ -951,6 +1137,27 @@
951
1137
  }
952
1138
  ]
953
1139
  },
1140
+ {
1141
+ "name": "notification-bell",
1142
+ "type": "registry:ui",
1143
+ "title": "Notification-bell",
1144
+ "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
+ "dependencies": [
1146
+ "dowel-ui@^0.26.0"
1147
+ ],
1148
+ "registryDependencies": [
1149
+ "https://lacodda.github.io/dowel/r/button.json",
1150
+ "https://lacodda.github.io/dowel/r/popover.json"
1151
+ ],
1152
+ "files": [
1153
+ {
1154
+ "path": "ui/notification-bell.tsx",
1155
+ "target": "@ui/notification-bell.tsx",
1156
+ "type": "registry:ui",
1157
+ "content": "import { Children, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\n\n/*\n * The bell in the title bar, lit by what asks to be looked at.\n *\n * A bell that is always lit is a bell nobody reads, so the count is the\n * product's decision and this draws it: nothing at zero, the number past\n * that, `9+` past nine. Pressing it does not leave the screen - the last few\n * entries open under it and the whole history is one more click, which is\n * the shape every product converged on once the first one tried a page.\n *\n * What is in the list is the product's: the rows are `children`, because a\n * journal entry, a failed upload and a comment are three different lines and\n * one component cannot draw them. What is shared is the frame around them -\n * the trigger with its badge, the heading with the button that clears the\n * count, the empty line, and the footer that leads to everything.\n *\n * Built on Popover and Button rather than on a `<div>` of its own, so the\n * panel opens, positions and closes the way every other panel does.\n */\n\nexport interface NotificationBellProps {\n /** How many things ask to be looked at. Nothing is drawn at zero. */\n count: number\n /** What the bell is called, for a screen reader and the tooltip. */\n label: string\n /** The heading of the panel. */\n title: string\n /** Open, controlled. Left out, the bell manages itself. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** The button that clears the count, shown only while there is one. */\n markAllLabel?: string\n onMarkAll?: () => void\n /** The footer button, which closes the panel and hands over. */\n seeAllLabel?: string\n onSeeAll?: () => void\n /** What the list says when there are no rows. */\n emptyLabel: string\n /** The rows. */\n children?: ReactNode\n /** While the product is clearing the count: the button waits. */\n busy?: boolean\n className?: string\n}\n\nexport function NotificationBell({\n count,\n label,\n title,\n open,\n onOpenChange,\n markAllLabel,\n onMarkAll,\n seeAllLabel,\n onSeeAll,\n emptyLabel,\n children,\n busy = false,\n className,\n}: NotificationBellProps) {\n const [own, setOwn] = useState(false)\n const isOpen = open ?? own\n const setOpen = (next: boolean) => {\n setOwn(next)\n onOpenChange?.(next)\n }\n const empty = Children.count(children) === 0\n\n return (\n <Popover open={isOpen} onOpenChange={setOpen}>\n <PopoverTrigger\n render={\n <Button variant=\"icon\" size=\"icon-sm\" className={cn('relative', className)} title={label} aria-label={label} />\n }\n >\n <Bell />\n {count > 0 ? (\n <span\n data-badge\n className={cn(\n 'absolute -right-0.5 -top-0.5 min-w-3.5 rounded-full bg-warn px-1',\n 'text-center font-mono text-[9px] leading-[14px] text-on-warn',\n )}\n >\n {count > 9 ? '9+' : count}\n </span>\n ) : null}\n </PopoverTrigger>\n <PopoverPopup align=\"end\" arrow={false} size=\"lg\" className=\"p-0\">\n <header className=\"flex items-center gap-2 border-b border-line px-3 py-2\">\n <h3 className=\"text-sm font-semibold\">{title}</h3>\n {count > 0 && markAllLabel ? (\n <Button variant=\"ghost\" size=\"sm\" className=\"ml-auto\" disabled={busy} onClick={onMarkAll}>\n {markAllLabel}\n </Button>\n ) : null}\n </header>\n <div className=\"max-h-80 overflow-y-auto px-3\">\n {empty ? <p className=\"py-3 text-sm text-dim\">{emptyLabel}</p> : children}\n </div>\n {seeAllLabel ? (\n <footer className=\"border-t border-line p-2\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full\"\n onClick={() => {\n setOpen(false)\n onSeeAll?.()\n }}\n >\n {seeAllLabel}\n </Button>\n </footer>\n ) : null}\n </PopoverPopup>\n </Popover>\n )\n}\n\nfunction Bell() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" aria-hidden>\n <path d=\"M4 11V7.5a4 4 0 0 1 8 0V11l1 1.5H3z\" strokeLinejoin=\"round\" />\n <path d=\"M6.5 13.5a1.5 1.5 0 0 0 3 0\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
1158
+ }
1159
+ ]
1160
+ },
954
1161
  {
955
1162
  "name": "number-field",
956
1163
  "type": "registry:ui",
@@ -958,7 +1165,7 @@
958
1165
  "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.",
959
1166
  "dependencies": [
960
1167
  "@base-ui/react",
961
- "dowel-ui@^0.24.0"
1168
+ "dowel-ui@^0.26.0"
962
1169
  ],
963
1170
  "registryDependencies": [
964
1171
  "https://lacodda.github.io/dowel/r/input.json"
@@ -978,7 +1185,7 @@
978
1185
  "title": "Number-format",
979
1186
  "description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
980
1187
  "dependencies": [
981
- "dowel-ui@^0.24.0"
1188
+ "dowel-ui@^0.26.0"
982
1189
  ],
983
1190
  "registryDependencies": [],
984
1191
  "files": [
@@ -996,7 +1203,7 @@
996
1203
  "title": "Page-size",
997
1204
  "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.",
998
1205
  "dependencies": [
999
- "dowel-ui@^0.24.0"
1206
+ "dowel-ui@^0.26.0"
1000
1207
  ],
1001
1208
  "registryDependencies": [
1002
1209
  "https://lacodda.github.io/dowel/r/select.json"
@@ -1016,7 +1223,7 @@
1016
1223
  "title": "Pagination",
1017
1224
  "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.",
1018
1225
  "dependencies": [
1019
- "dowel-ui@^0.24.0"
1226
+ "dowel-ui@^0.26.0"
1020
1227
  ],
1021
1228
  "registryDependencies": [
1022
1229
  "https://lacodda.github.io/dowel/r/button.json"
@@ -1037,7 +1244,7 @@
1037
1244
  "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.",
1038
1245
  "dependencies": [
1039
1246
  "class-variance-authority",
1040
- "dowel-ui@^0.24.0"
1247
+ "dowel-ui@^0.26.0"
1041
1248
  ],
1042
1249
  "registryDependencies": [],
1043
1250
  "files": [
@@ -1055,7 +1262,7 @@
1055
1262
  "title": "Password-field",
1056
1263
  "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.",
1057
1264
  "dependencies": [
1058
- "dowel-ui@^0.24.0"
1265
+ "dowel-ui@^0.26.0"
1059
1266
  ],
1060
1267
  "registryDependencies": [
1061
1268
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1077,7 +1284,7 @@
1077
1284
  "dependencies": [
1078
1285
  "@base-ui/react",
1079
1286
  "class-variance-authority",
1080
- "dowel-ui@^0.24.0"
1287
+ "dowel-ui@^0.26.0"
1081
1288
  ],
1082
1289
  "registryDependencies": [],
1083
1290
  "files": [
@@ -1097,7 +1304,7 @@
1097
1304
  "dependencies": [
1098
1305
  "@base-ui/react",
1099
1306
  "class-variance-authority",
1100
- "dowel-ui@^0.24.0"
1307
+ "dowel-ui@^0.26.0"
1101
1308
  ],
1102
1309
  "registryDependencies": [],
1103
1310
  "files": [
@@ -1117,7 +1324,7 @@
1117
1324
  "dependencies": [
1118
1325
  "@base-ui/react",
1119
1326
  "class-variance-authority",
1120
- "dowel-ui@^0.24.0"
1327
+ "dowel-ui@^0.26.0"
1121
1328
  ],
1122
1329
  "registryDependencies": [],
1123
1330
  "files": [
@@ -1156,7 +1363,7 @@
1156
1363
  "dependencies": [
1157
1364
  "@base-ui/react",
1158
1365
  "class-variance-authority",
1159
- "dowel-ui@^0.24.0"
1366
+ "dowel-ui@^0.26.0"
1160
1367
  ],
1161
1368
  "registryDependencies": [],
1162
1369
  "files": [
@@ -1174,7 +1381,7 @@
1174
1381
  "title": "Rating-scale",
1175
1382
  "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\".",
1176
1383
  "dependencies": [
1177
- "dowel-ui@^0.24.0"
1384
+ "dowel-ui@^0.26.0"
1178
1385
  ],
1179
1386
  "registryDependencies": [],
1180
1387
  "files": [
@@ -1192,7 +1399,7 @@
1192
1399
  "title": "Relative-time",
1193
1400
  "description": "The relative-time primitive.",
1194
1401
  "dependencies": [
1195
- "dowel-ui@^0.24.0"
1402
+ "dowel-ui@^0.26.0"
1196
1403
  ],
1197
1404
  "registryDependencies": [],
1198
1405
  "files": [
@@ -1204,13 +1411,31 @@
1204
1411
  }
1205
1412
  ]
1206
1413
  },
1414
+ {
1415
+ "name": "reorderable-list",
1416
+ "type": "registry:ui",
1417
+ "title": "Reorderable-list",
1418
+ "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
+ "dependencies": [
1420
+ "dowel-ui@^0.26.0"
1421
+ ],
1422
+ "registryDependencies": [],
1423
+ "files": [
1424
+ {
1425
+ "path": "ui/reorderable-list.tsx",
1426
+ "target": "@ui/reorderable-list.tsx",
1427
+ "type": "registry:ui",
1428
+ "content": "import {\n useCallback,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Drag a row of a vertical list up or down to put it somewhere else.\n *\n * The columns in a column picker, the stops of a dial, the roles of a\n * profile. A hook and a grip rather than a list component: the rows are\n * already something else's - a menu's items, a form's fields - and a\n * component wrapping them would have to reproduce whatever that something\n * else does.\n *\n * Pointer events, not HTML5 drag-and-drop, for the reason given at\n * ColumnResizeHandle: a desktop shell that takes file drops never lets a\n * `dragstart` reach the page. The grip takes pointer capture on pointerdown,\n * so the rows underneath never see the drag and a menu's own highlighting\n * does not flicker down the list as the pointer crosses it.\n *\n * Nothing moves until the pointer is let go. Live reordering looks better\n * for a second and costs a write per crossed row - to a profile, that is a\n * request per row - and the row being dragged has already been picked up,\n * so the reader is watching the line that says where it lands. The hook\n * reports that line as `slot`, and the caller draws it.\n *\n * The keyboard's way is Alt with an arrow on the focused row. Plain arrows\n * are how a list is walked, and taking them for moving would leave no way\n * to walk it; the modifier is the one screen readers and editors already\n * use for \"move this line\".\n */\n\nexport interface ReorderOptions<K extends string> {\n /** The ids in their current order. Every row carries its id in\n * `data-reorder-id`, which `rowProps` sets. */\n order: readonly K[]\n /** Put `id` at index `to` of the resulting list. Called once per drop or\n * per keypress, never during a drag. */\n onMove: (id: K, to: number) => void\n disabled?: boolean\n}\n\nexport interface Reorder<K extends string> {\n /** Spread on the element holding the rows: the rows are found under it.\n * A callback ref rather than a ref object, so that nothing ref-shaped is\n * handed back and the compiler does not take the whole result for one. */\n listProps: { ref: (element: HTMLElement | null) => void }\n /** The row being dragged, or null. */\n dragging: K | null\n /** Where a drop would go, as an insertion point: between the rows at\n * `slot - 1` and `slot`, in the order as it is. Null while nothing is\n * dragged, or while the drop would change nothing. */\n slot: number | null\n /** The insertion point's distance from the top of the list element, in\n * pixels, for drawing the line. */\n slotOffset: number | null\n /** Spread on the grip: the pointer path. */\n gripProps: (id: K) => (HTMLAttributes<HTMLElement>)\n /** Spread on the row: the id and the keyboard path. The `ref` is a\n * callback that returns its cleanup, as React 19 allows; a host that merges\n * refs and drops the cleanup leaves a listener on a node that is gone,\n * which is harmless. */\n rowProps: (id: K) => {\n 'data-reorder-id': K\n ref: (row: HTMLElement | null) => void | (() => void)\n }\n}\n\nexport function useReorder<K extends string>({ order, onMove, disabled }: ReorderOptions<K>): Reorder<K> {\n const listRef = useRef<HTMLElement | null>(null)\n const [dragging, setDragging] = useState<K | null>(null)\n // The slot and where to draw it, settled together in the move handler: the\n // boxes it is read from are refs, and refs are not for reading in render.\n const [drop, setDrop] = useState<{ slot: number; offset: number } | null>(null)\n // The rows' boxes as they were when the drag began; nothing moves during\n // it, so reading them once is reading them right.\n const boxes = useRef<{ top: number; bottom: number }[]>([])\n const listTop = useRef(0)\n\n // The slot under a pointer: how many rows have their middle above it.\n const slotAt = (y: number): number => {\n let count = 0\n for (const box of boxes.current) if ((box.top + box.bottom) / 2 < y) count += 1\n return count\n }\n\n // A slot is an insertion point in the list as drawn; the move wants the\n // index in the list as it will be, with the row gone from where it was.\n const destination = (from: number, at: number): number => (at > from ? at - 1 : at)\n\n const begin = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (disabled || event.button !== 0) return\n const list = listRef.current\n if (list === null) return\n event.preventDefault()\n event.stopPropagation()\n\n listTop.current = list.getBoundingClientRect().top\n // By attribute rather than by selector, so an id needs no escaping.\n const rows = [...list.querySelectorAll<HTMLElement>('[data-reorder-id]')]\n boxes.current = order.map((rowId) => {\n const box = rows.find((row) => row.getAttribute('data-reorder-id') === rowId)?.getBoundingClientRect()\n return box ? { top: box.top, bottom: box.bottom } : { top: 0, bottom: 0 }\n })\n event.currentTarget.setPointerCapture(event.pointerId)\n setDragging(id)\n setDrop(null)\n }\n\n // The insertion point's distance from the top of the list: the top of the\n // row it goes before, or the bottom of the last row.\n const offsetOf = (at: number): number => {\n const rows = boxes.current\n const edge = at < rows.length ? rows[at]?.top : rows[rows.length - 1]?.bottom\n return (edge ?? 0) - listTop.current\n }\n\n const move = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n // Dropping a row back where it is - the slot just above or just below\n // itself - is no move, and drawing a line there would promise one.\n setDrop(destination(from, at) === from ? null : { slot: at, offset: offsetOf(at) })\n }\n\n const end = (id: K, event: ReactPointerEvent<HTMLElement>) => {\n if (dragging !== id) return\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n const from = order.indexOf(id)\n const at = slotAt(event.clientY)\n const to = destination(from, at)\n setDragging(null)\n setDrop(null)\n if (event.type !== 'pointercancel' && to !== from) onMove(id, to)\n }\n\n // Rebuilt every render on purpose: they close over the drag state and the\n // order, and memoising them would mean listing both and getting one wrong.\n const gripProps = (id: K): HTMLAttributes<HTMLElement> => ({\n onPointerDown: (event) => begin(id, event),\n onPointerMove: (event) => move(id, event),\n onPointerUp: (event) => end(id, event),\n onPointerCancel: (event) => end(id, event),\n // The click the drop leaves behind must not reach the row: in a menu it\n // would toggle the item that was only meant to be moved.\n onClick: (event) => event.stopPropagation(),\n })\n\n // The keyboard path is a native listener on the row, not a React one. A\n // menu popup handles the arrow keys itself and stops them on the way up,\n // so a React `onKeyDown` on the item - dispatched from the root, above the\n // popup - never hears them; a listener on the row itself fires at the\n // target first, whatever the ancestors do afterwards. The listener reads\n // the row's id and the latest order off refs rather than closing over\n // them, so one stable callback serves every row and nothing is re-bound\n // on each render.\n const latest = useRef({ order, onMove, disabled })\n useEffect(() => {\n latest.current = { order, onMove, disabled }\n })\n\n const listen = useCallback((row: HTMLElement | null) => {\n if (row === null) return\n const onKeyDown = (event: KeyboardEvent) => {\n const { order: current, onMove: put, disabled: off } = latest.current\n if (off || !event.altKey) return\n const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0\n if (step === 0) return\n const id = row.getAttribute('data-reorder-id') as K | null\n if (id === null) return\n const to = current.indexOf(id) + step\n if (to < 0 || to >= current.length) return\n // Stopped here so the list's own arrow handling does not also walk the\n // focus off the row that was just moved.\n event.preventDefault()\n event.stopPropagation()\n put(id, to)\n }\n row.addEventListener('keydown', onKeyDown)\n return () => row.removeEventListener('keydown', onKeyDown)\n }, [])\n\n const rowProps = (id: K) => ({\n 'data-reorder-id': id,\n ref: listen,\n })\n\n return {\n listProps: {\n ref: (element) => {\n listRef.current = element\n },\n },\n dragging,\n slot: drop?.slot ?? null,\n slotOffset: drop?.offset ?? null,\n gripProps,\n rowProps,\n }\n}\n\n/** The handle a row is picked up by. Decorative to a screen reader - the\n * keyboard path is on the row itself - so it carries no role and no label. */\nexport function ReorderGrip({ className, ...props }: HTMLAttributes<HTMLElement>) {\n return (\n <span\n aria-hidden\n data-reorder-grip\n className={cn(\n 'inline-flex shrink-0 cursor-grab touch-none select-none text-faint',\n 'active:cursor-grabbing [&_svg]:size-3.5',\n className,\n )}\n {...props}\n >\n <svg viewBox=\"0 0 16 16\" fill=\"currentColor\" aria-hidden>\n <circle cx=\"6\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"3.5\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"8\" r=\"1.2\" />\n <circle cx=\"6\" cy=\"12.5\" r=\"1.2\" />\n <circle cx=\"10\" cy=\"12.5\" r=\"1.2\" />\n </svg>\n </span>\n )\n}\n\n/** The line where a dragged row would land. Positioned by the caller from\n * `slotOffset`, inside the element `listProps` is on - which has to be\n * positioned itself. */\nexport function ReorderIndicator({ offset, className }: { offset: number | null; className?: string }) {\n if (offset === null) return null\n return (\n <div\n aria-hidden\n data-reorder-indicator\n className={cn('pointer-events-none absolute inset-x-1 h-0.5 -translate-y-px rounded-full bg-accent', className)}\n style={{ top: offset }}\n />\n )\n}\n"
1429
+ }
1430
+ ]
1431
+ },
1207
1432
  {
1208
1433
  "name": "save-state",
1209
1434
  "type": "registry:ui",
1210
1435
  "title": "Save-state",
1211
1436
  "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.",
1212
1437
  "dependencies": [
1213
- "dowel-ui@^0.24.0"
1438
+ "dowel-ui@^0.26.0"
1214
1439
  ],
1215
1440
  "registryDependencies": [
1216
1441
  "https://lacodda.github.io/dowel/r/spinner.json"
@@ -1230,7 +1455,7 @@
1230
1455
  "title": "Search-field",
1231
1456
  "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.",
1232
1457
  "dependencies": [
1233
- "dowel-ui@^0.24.0"
1458
+ "dowel-ui@^0.26.0"
1234
1459
  ],
1235
1460
  "registryDependencies": [
1236
1461
  "https://lacodda.github.io/dowel/r/input.json",
@@ -1246,6 +1471,25 @@
1246
1471
  }
1247
1472
  ]
1248
1473
  },
1474
+ {
1475
+ "name": "section-nav",
1476
+ "type": "registry:ui",
1477
+ "title": "Section-nav",
1478
+ "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
+ "dependencies": [
1480
+ "@base-ui/react",
1481
+ "dowel-ui@^0.26.0"
1482
+ ],
1483
+ "registryDependencies": [],
1484
+ "files": [
1485
+ {
1486
+ "path": "ui/section-nav.tsx",
1487
+ "target": "@ui/section-nav.tsx",
1488
+ "type": "registry:ui",
1489
+ "content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n}\n\nexport function SectionNav({ label, items, activeId, render, onSelect, className, ...props }: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className=\"px-2.5 pb-2 text-2xs font-medium uppercase tracking-caption text-faint\">\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n className: cn(\n 'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n 'hover:bg-soft hover:text-text [&_svg]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {item.label}\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
1490
+ }
1491
+ ]
1492
+ },
1249
1493
  {
1250
1494
  "name": "select",
1251
1495
  "type": "registry:ui",
@@ -1254,7 +1498,7 @@
1254
1498
  "dependencies": [
1255
1499
  "@base-ui/react",
1256
1500
  "class-variance-authority",
1257
- "dowel-ui@^0.24.0"
1501
+ "dowel-ui@^0.26.0"
1258
1502
  ],
1259
1503
  "registryDependencies": [
1260
1504
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1290,7 +1534,7 @@
1290
1534
  "title": "Skeleton",
1291
1535
  "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.",
1292
1536
  "dependencies": [
1293
- "dowel-ui@^0.24.0"
1537
+ "dowel-ui@^0.26.0"
1294
1538
  ],
1295
1539
  "registryDependencies": [],
1296
1540
  "files": [
@@ -1309,7 +1553,7 @@
1309
1553
  "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\".",
1310
1554
  "dependencies": [
1311
1555
  "@base-ui/react",
1312
- "dowel-ui@^0.24.0"
1556
+ "dowel-ui@^0.26.0"
1313
1557
  ],
1314
1558
  "registryDependencies": [],
1315
1559
  "files": [
@@ -1328,7 +1572,7 @@
1328
1572
  "description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
1329
1573
  "dependencies": [
1330
1574
  "class-variance-authority",
1331
- "dowel-ui@^0.24.0"
1575
+ "dowel-ui@^0.26.0"
1332
1576
  ],
1333
1577
  "registryDependencies": [],
1334
1578
  "files": [
@@ -1347,7 +1591,7 @@
1347
1591
  "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.",
1348
1592
  "dependencies": [
1349
1593
  "class-variance-authority",
1350
- "dowel-ui@^0.24.0"
1594
+ "dowel-ui@^0.26.0"
1351
1595
  ],
1352
1596
  "registryDependencies": [],
1353
1597
  "files": [
@@ -1359,6 +1603,24 @@
1359
1603
  }
1360
1604
  ]
1361
1605
  },
1606
+ {
1607
+ "name": "splash",
1608
+ "type": "registry:ui",
1609
+ "title": "Splash",
1610
+ "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
+ "dependencies": [
1612
+ "dowel-ui@^0.26.0"
1613
+ ],
1614
+ "registryDependencies": [],
1615
+ "files": [
1616
+ {
1617
+ "path": "ui/splash.tsx",
1618
+ "target": "@ui/splash.tsx",
1619
+ "type": "registry:ui",
1620
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * What the window shows while the application is opening.\n *\n * A desktop product has a second or two between the window appearing and\n * the first screen being ready - a workspace to open, a database to migrate,\n * a plugin to start - and a blank window for that long reads as a crash. So\n * the window shows the product instead: the mark, the name, the promise, the\n * version, and a bar that sweeps until there is something to draw.\n *\n * It is the second half of a pattern, and the first half is not React. The\n * page paints the same picture in inline CSS before the bundle arrives, so\n * that the window is never blank at all; this component takes over from it\n * on the first render, with the same geometry so nothing jumps, and adds the\n * two lines only the application can say - what it is doing, in the person's\n * language, and one thing worth knowing. The static half is in the docs.\n *\n * It is a status region, not a dialog: the reader is told what is happening\n * and cannot act on it. The sweep is a `<style>` of its own rather than a\n * theme keyframe, because a product installs the theme on its first day and\n * this once; and under reduced motion the theme stops every animation dead,\n * which would leave the sweep parked off the end of its track - so the bar\n * is drawn full and still instead.\n */\n\nexport interface SplashProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The product's mark, drawn at 56px. */\n mark?: ReactNode\n /** The product's name. */\n name: string\n /** The line under the name. */\n tagline?: string\n /** The version, drawn in the mono face; the `v` is the product's to add. */\n version?: string\n /** What the application is doing right now. */\n status?: string\n /** One thing worth knowing while it does it. */\n tip?: string\n /** Whether the bar is sweeping. Still and full when the application is\n * waiting on something that has no progress, such as a person. */\n busy?: boolean\n}\n\n/** The sweep, named with a prefix so a product's own `sweep` cannot collide\n * with it in the one document both end up in.\n *\n * The class below writes the name out rather than interpolating this\n * constant, and that is not carelessness: Tailwind finds its classes by\n * scanning the source text, and a class assembled from a template is one it\n * never sees - the bar rendered with the right class name and no CSS behind\n * it, and the stand showed an empty track. The test compiles the class to\n * make sure the two spellings agree. */\nconst SWEEP = 'dowel-splash-sweep'\nconst SWEEPING = 'animate-[dowel-splash-sweep_1.1s_ease-in-out_infinite]'\n\nexport function Splash({\n mark,\n name,\n tagline,\n version,\n status,\n tip,\n busy = true,\n className,\n ...props\n}: SplashProps) {\n return (\n <div\n role=\"status\"\n aria-live=\"polite\"\n className={cn(\n 'fixed inset-0 flex flex-col items-center justify-center gap-2.5 bg-bg text-text select-none',\n className,\n )}\n {...props}\n >\n <style>{`@keyframes ${SWEEP} { to { left: 100%; } }`}</style>\n {mark ? <div className=\"mb-1.5 size-14 [&>svg]:size-full\">{mark}</div> : null}\n <div className=\"text-[26px] leading-none font-semibold tracking-[0.02em]\">{name}</div>\n {tagline ? <div className=\"text-sm text-dim\">{tagline}</div> : null}\n {version ? <div className=\"mt-1.5 font-mono text-2xs text-faint\">{version}</div> : null}\n <div className=\"relative mt-4 h-0.5 w-40 overflow-hidden rounded-full bg-line\">\n <div\n data-sweep={busy ? 'on' : 'off'}\n className={cn(\n 'absolute top-0 h-full rounded-full bg-accent',\n busy\n ? cn(\n '-left-2/5 w-2/5',\n SWEEPING,\n 'motion-reduce:left-0 motion-reduce:w-full motion-reduce:animate-none',\n )\n : 'left-0 w-full',\n )}\n />\n </div>\n {status ? <p className=\"mt-3 text-xs text-dim\">{status}</p> : null}\n {tip ? <p className=\"text-2xs text-faint\">{tip}</p> : null}\n </div>\n )\n}\n"
1621
+ }
1622
+ ]
1623
+ },
1362
1624
  {
1363
1625
  "name": "stat-tile",
1364
1626
  "type": "registry:ui",
@@ -1366,7 +1628,7 @@
1366
1628
  "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.",
1367
1629
  "dependencies": [
1368
1630
  "class-variance-authority",
1369
- "dowel-ui@^0.24.0"
1631
+ "dowel-ui@^0.26.0"
1370
1632
  ],
1371
1633
  "registryDependencies": [],
1372
1634
  "files": [
@@ -1385,7 +1647,7 @@
1385
1647
  "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.",
1386
1648
  "dependencies": [
1387
1649
  "@base-ui/react",
1388
- "dowel-ui@^0.24.0"
1650
+ "dowel-ui@^0.26.0"
1389
1651
  ],
1390
1652
  "registryDependencies": [],
1391
1653
  "files": [
@@ -1420,7 +1682,7 @@
1420
1682
  "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.",
1421
1683
  "dependencies": [
1422
1684
  "class-variance-authority",
1423
- "dowel-ui@^0.24.0"
1685
+ "dowel-ui@^0.26.0"
1424
1686
  ],
1425
1687
  "registryDependencies": [
1426
1688
  "https://lacodda.github.io/dowel/r/table-sort.json"
@@ -1441,7 +1703,7 @@
1441
1703
  "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
1442
1704
  "dependencies": [
1443
1705
  "class-variance-authority",
1444
- "dowel-ui@^0.24.0"
1706
+ "dowel-ui@^0.26.0"
1445
1707
  ],
1446
1708
  "registryDependencies": [
1447
1709
  "https://lacodda.github.io/dowel/r/chip.json",
@@ -1462,7 +1724,7 @@
1462
1724
  "title": "Textarea",
1463
1725
  "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.",
1464
1726
  "dependencies": [
1465
- "dowel-ui@^0.24.0"
1727
+ "dowel-ui@^0.26.0"
1466
1728
  ],
1467
1729
  "registryDependencies": [
1468
1730
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1482,7 +1744,7 @@
1482
1744
  "title": "Time-field",
1483
1745
  "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`.",
1484
1746
  "dependencies": [
1485
- "dowel-ui@^0.24.0"
1747
+ "dowel-ui@^0.26.0"
1486
1748
  ],
1487
1749
  "registryDependencies": [
1488
1750
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1504,7 +1766,7 @@
1504
1766
  "dependencies": [
1505
1767
  "@base-ui/react",
1506
1768
  "class-variance-authority",
1507
- "dowel-ui@^0.24.0"
1769
+ "dowel-ui@^0.26.0"
1508
1770
  ],
1509
1771
  "registryDependencies": [],
1510
1772
  "files": [
@@ -1524,7 +1786,7 @@
1524
1786
  "dependencies": [
1525
1787
  "@base-ui/react",
1526
1788
  "class-variance-authority",
1527
- "dowel-ui@^0.24.0"
1789
+ "dowel-ui@^0.26.0"
1528
1790
  ],
1529
1791
  "registryDependencies": [],
1530
1792
  "files": [
@@ -1559,7 +1821,7 @@
1559
1821
  "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.",
1560
1822
  "dependencies": [
1561
1823
  "class-variance-authority",
1562
- "dowel-ui@^0.24.0"
1824
+ "dowel-ui@^0.26.0"
1563
1825
  ],
1564
1826
  "registryDependencies": [
1565
1827
  "https://lacodda.github.io/dowel/r/track-segments.json"
@@ -1595,7 +1857,7 @@
1595
1857
  "title": "Tree-view",
1596
1858
  "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.",
1597
1859
  "dependencies": [
1598
- "dowel-ui@^0.24.0"
1860
+ "dowel-ui@^0.26.0"
1599
1861
  ],
1600
1862
  "registryDependencies": [
1601
1863
  "https://lacodda.github.io/dowel/r/tree-rows.json"
@@ -1615,7 +1877,7 @@
1615
1877
  "title": "Truncate",
1616
1878
  "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.",
1617
1879
  "dependencies": [
1618
- "dowel-ui@^0.24.0"
1880
+ "dowel-ui@^0.26.0"
1619
1881
  ],
1620
1882
  "registryDependencies": [],
1621
1883
  "files": [
@@ -1633,7 +1895,7 @@
1633
1895
  "title": "Virtual-list",
1634
1896
  "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.",
1635
1897
  "dependencies": [
1636
- "dowel-ui@^0.24.0"
1898
+ "dowel-ui@^0.26.0"
1637
1899
  ],
1638
1900
  "registryDependencies": [],
1639
1901
  "files": [
@@ -1645,6 +1907,25 @@
1645
1907
  }
1646
1908
  ]
1647
1909
  },
1910
+ {
1911
+ "name": "window-frame",
1912
+ "type": "registry:ui",
1913
+ "title": "Window-frame",
1914
+ "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
+ "dependencies": [
1916
+ "@tauri-apps/api",
1917
+ "dowel-ui@^0.26.0"
1918
+ ],
1919
+ "registryDependencies": [],
1920
+ "files": [
1921
+ {
1922
+ "path": "ui/window-frame.tsx",
1923
+ "target": "@ui/window-frame.tsx",
1924
+ "type": "registry:ui",
1925
+ "content": "import {\n useCallback,\n useEffect,\n useState,\n type CSSProperties,\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n} from 'react'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { cn } from 'dowel-ui'\n\n/** The eight compass names Tauri resizes by. Read off the method rather than\n * imported: the package declares the type without exporting it. */\ntype ResizeDirection = Parameters<ReturnType<typeof getCurrentWindow>['startResizeDragging']>[0]\n\n/*\n * The window's own frame, for a window that has no system frame.\n *\n * With `decorations: false` the system draws nothing, so everything it used\n * to do is the page's: dragging the window by its title bar, double-click to\n * maximise, the three buttons, and the edges you grab to resize. Each is\n * small; the reason to take them on at all is that a system title bar over an\n * application title bar costs a strip of every laptop screen for nothing.\n * scheda made the trade first and kilna copied it, which is the second\n * consumer the line asks for before anything becomes shared.\n *\n * Four exports, and they are used together: `WindowButtons` in the bar,\n * `useTitleBarGestures()` spread on the bar, `ResizeEdges` once at the root,\n * and `useMaximized()` for anything else that changes shape with the window.\n *\n * Outside Tauri - a browser, a test, the stand - there is no window to drive.\n * Every call goes through `currentWindow()`, which answers null when the\n * Tauri bridge is absent, so the chrome renders and does nothing rather than\n * throwing on the first click. A product's own storybook runs in a browser\n * too, and a title bar that crashes it is a title bar nobody previews.\n */\n\n/** The Tauri window, or null where there is none to drive. The bridge is what\n * `getCurrentWindow` reads its label from, so its absence is the test. */\nfunction currentWindow() {\n return '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null\n}\n\n/** Whether the window is maximised, kept current as the window changes.\n *\n * The window can be maximised without our buttons - a drag to the top edge,\n * the keyboard, a snap layout - so the answer follows the window rather than\n * our own last click. */\nexport function useMaximized(): boolean {\n const [maximized, setMaximized] = useState(false)\n\n useEffect(() => {\n const target = currentWindow()\n if (!target) return\n const read = () => {\n target.isMaximized().then(setMaximized).catch(() => undefined)\n }\n read()\n const unlisten = target.onResized(read)\n return () => {\n unlisten.then((stop) => stop()).catch(() => undefined)\n }\n }, [])\n\n return maximized\n}\n\nexport interface WindowButtonsProps {\n /** What each button is called. Required, and deliberately without a\n * default: a string this component invents is a string the product cannot\n * translate. `restore` replaces `maximize` while the window is maximised. */\n labels: { minimize: string; maximize: string; restore: string; close: string }\n className?: string\n}\n\ntype Control = keyof WindowButtonsProps['labels']\n\n/** The four glyphs, drawn in one stroke on a ten-pixel grid - the size the\n * system's own were, so the bar reads as the window's and not as a toolbar. */\nconst GLYPH: Record<Control, ReactNode> = {\n minimize: <path d=\"M0 5h10\" />,\n maximize: <rect x=\"0.5\" y=\"0.5\" width=\"9\" height=\"9\" />,\n restore: <path d=\"M2.5 2.5V0.5h7v7h-2M0.5 2.5h7v7h-7z\" />,\n close: <path d=\"M0 0l10 10M10 0L0 10\" />,\n}\n\n/** The window controls, in the order Windows puts them. */\nexport function WindowButtons({ labels, className }: WindowButtonsProps) {\n const maximized = useMaximized()\n const controls: [Control, () => unknown][] = [\n ['minimize', () => currentWindow()?.minimize()],\n [maximized ? 'restore' : 'maximize', () => currentWindow()?.toggleMaximize()],\n ['close', () => currentWindow()?.close()],\n ]\n\n return (\n <div className={cn('flex h-full shrink-0 items-stretch', className)}>\n {controls.map(([name, act]) => (\n <button\n key={name}\n type=\"button\"\n aria-label={labels[name]}\n title={labels[name]}\n onClick={() => void act()}\n className={cn(\n 'flex h-full w-[46px] cursor-default items-center justify-center text-dim transition-colors',\n 'hover:bg-soft hover:text-text',\n // The close button is the one that must not be mistaken for its\n // neighbours: it goes red under the pointer, as on every desktop.\n name === 'close' && 'hover:bg-bad hover:text-on-bad',\n )}\n >\n <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" aria-hidden>\n {GLYPH[name]}\n </svg>\n </button>\n ))}\n </div>\n )\n}\n\n/** A press on a control has already been handled by the control. */\nconst shouldHandle = (target: EventTarget | null) =>\n !(target as HTMLElement | null)?.closest(\n 'button, a, input, textarea, [role=\"menu\"], [role=\"menuitem\"], [role=\"tab\"], [role=\"dialog\"]',\n )\n\n/** How far the pointer moves before a press becomes a drag, in pixels. */\nconst THRESHOLD = 4\n\n/** Makes an element behave like a title bar: drag to move, double-click to\n * maximise. Both are what the system used to do for free. Spread the result\n * on the bar: `<header {...useTitleBarGestures()}>`.\n *\n * Two handlers rather than one. A `pointerdown` cannot recognise a double\n * click: its `detail` counts clicks of the *mouse* event sequence, and the\n * second press still arrives as 1 - reading it there fired `startDragging`\n * three times over a double click and toggled nothing. So the press starts a\n * drag, and `dblclick`, which the browser is the one qualified to detect,\n * maximises.\n *\n * Dragging starts on the first movement, not on the press. `startDragging`\n * hands the window over to the system - which is what keeps snap layouts and\n * drag-to-edge working - but from that moment the webview stops seeing the\n * mouse. Calling it on `pointerdown` ate the second click of every double\n * click, and maximising never happened. */\nexport function useTitleBarGestures() {\n const onPointerDown = useCallback((event: PointerEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n\n const start = { x: event.clientX, y: event.clientY }\n const onMove = (move: globalThis.PointerEvent) => {\n if (Math.abs(move.clientX - start.x) < THRESHOLD && Math.abs(move.clientY - start.y) < THRESHOLD) {\n return\n }\n stop()\n void currentWindow()?.startDragging()\n }\n const stop = () => {\n window.removeEventListener('pointermove', onMove)\n window.removeEventListener('pointerup', stop)\n window.removeEventListener('pointercancel', stop)\n }\n\n window.addEventListener('pointermove', onMove)\n window.addEventListener('pointerup', stop)\n window.addEventListener('pointercancel', stop)\n }, [])\n\n const onDoubleClick = useCallback((event: MouseEvent) => {\n if (event.button !== 0 || !shouldHandle(event.target)) return\n void currentWindow()?.toggleMaximize()\n }, [])\n\n return { onPointerDown, onDoubleClick }\n}\n\n/** The eight edges and corners a frameless window still has to offer. */\nconst RESIZE_HANDLES: readonly ResizeDirection[] = [\n 'North',\n 'South',\n 'East',\n 'West',\n 'NorthEast',\n 'NorthWest',\n 'SouthEast',\n 'SouthWest',\n]\n\nconst EDGE = 5\nconst CORNER = 10\n\n/** Where each strip sits and which cursor it shows. Inline styles rather than\n * classes: eight positions of a few pixels each are geometry, not design. */\nconst EDGE_STYLE: Record<ResizeDirection, CSSProperties> = {\n North: { top: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n South: { bottom: 0, left: CORNER, right: CORNER, height: EDGE, cursor: 'ns-resize' },\n East: { top: CORNER, bottom: CORNER, right: 0, width: EDGE, cursor: 'ew-resize' },\n West: { top: CORNER, bottom: CORNER, left: 0, width: EDGE, cursor: 'ew-resize' },\n NorthEast: { top: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n NorthWest: { top: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthEast: { bottom: 0, right: 0, width: CORNER, height: CORNER, cursor: 'nwse-resize' },\n SouthWest: { bottom: 0, left: 0, width: CORNER, height: CORNER, cursor: 'nesw-resize' },\n}\n\nexport interface ResizeEdgesProps {\n /** Merged into every strip. `fixed` to the viewport by default, which is\n * where a window's edges are; `absolute` puts them on the nearest\n * positioned box instead, for a frame drawn inside a page. */\n className?: string\n}\n\n/** Invisible strips along the window's edges.\n *\n * A frameless window has no border to grab, so these put one back. They sit\n * outside the flow, above everything, and are only a few pixels wide -\n * enough to hit, not enough to steal a click meant for the text. A maximised\n * window has no edges to drag, and leaving the strips in place would mean\n * the top few pixels of the title bar stop taking clicks. */\nexport function ResizeEdges({ className }: ResizeEdgesProps) {\n const maximized = useMaximized()\n if (maximized) return null\n\n return (\n <>\n {RESIZE_HANDLES.map((direction) => (\n <div\n key={direction}\n aria-hidden\n data-resize-edge={direction}\n className={cn('fixed [z-index:var(--z-floating)]', className)}\n style={EDGE_STYLE[direction]}\n onPointerDown={(event) => {\n if (event.button !== 0) return\n event.preventDefault()\n void currentWindow()?.startResizeDragging(direction)\n }}\n />\n ))}\n </>\n )\n}\n"
1926
+ }
1927
+ ]
1928
+ },
1648
1929
  {
1649
1930
  "extends": "none",
1650
1931
  "name": "app",