dowel-ui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@
14
14
  "path": "dowel/theme.css",
15
15
  "target": "~/dowel/theme.css",
16
16
  "type": "registry:file",
17
- "content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. `raise` keeps its original value, so nothing shifts under\n * the products already using it; the other two are the steps either side. */\n --shadow-lift: 0 2px 8px rgb(0 0 0 / 0.3);\n --shadow-raise: 0 10px 34px rgb(0 0 0 / 0.45);\n --shadow-float: 0 24px 60px rgb(0 0 0 / 0.55);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /* Easing. `out` for anything the user asked for - it arrives fast and\n * settles, which reads as responsive. `in-out` for something moving on its\n * own. `in` is deliberately absent: it starts slowly, which on a control\n * reads as lag. */\n --ease-out: cubic-bezier(0.2, 0, 0, 1);\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n /*\n * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
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"
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; }"
@@ -293,7 +293,7 @@
293
293
  "dependencies": [
294
294
  "@base-ui/react",
295
295
  "class-variance-authority",
296
- "dowel-ui@^0.22.0"
296
+ "dowel-ui@^0.24.0"
297
297
  ],
298
298
  "registryDependencies": [],
299
299
  "files": [
@@ -305,6 +305,64 @@
305
305
  }
306
306
  ]
307
307
  },
308
+ {
309
+ "name": "activity-heatmap",
310
+ "type": "registry:ui",
311
+ "title": "Activity-heatmap",
312
+ "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
+ "dependencies": [
314
+ "class-variance-authority",
315
+ "dowel-ui@^0.24.0"
316
+ ],
317
+ "registryDependencies": [
318
+ "https://lacodda.github.io/dowel/r/activity-weeks.json"
319
+ ],
320
+ "files": [
321
+ {
322
+ "path": "ui/activity-heatmap.tsx",
323
+ "target": "@ui/activity-heatmap.tsx",
324
+ "type": "registry:ui",
325
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport {\n weekdayRows,\n weeks,\n type ActivityEntry,\n type Cell,\n type Weekday,\n} from './activity-weeks'\n\n/*\n * A year of activity, one square per day.\n *\n * The shape everyone recognises: weeks as columns, weekdays as rows, time\n * running left to right, and a value carried by how dark a square is. Two\n * products of the line asked for it by name before it existed.\n *\n * **A cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, something still under way, and a date outside the range\n * asked for are four different facts. A grid that paints the first as the\n * palest shade of the second tells the reader somebody did nothing on a day\n * nobody reported - a claim invented by the drawing. So each has its own\n * treatment: a value is filled from the heat ramp, nothing recorded is the\n * bare empty square, something under way is outlined rather than filled\n * (there is no figure to shade, and any fill would be a number nobody gave),\n * and a padding square is drawn faintest of all, because the caller never\n * asked about it.\n *\n * **The legend is not decoration.** The scale is relative - the darkest square\n * is the busiest day in *this* grid, not a standard - so the grid says what\n * its own ceiling is. Without that, five shades look like an absolute measure\n * of a full day, which is a thing this component has no opinion about.\n *\n * **Colour is never the only channel.** Every square carries its date and its\n * figure in words, on a `title` and as its accessible name: a grid of five\n * shades says nothing to a screen reader, and little to anyone who does not\n * separate five blues.\n */\n\nexport const activityHeatmapVariants = cva('inline-flex flex-col gap-2', {\n variants: {\n size: {\n /* The year view, where a square is small enough that 53 columns fit. */\n sm: '[--cell:10px] [--gap:2px]',\n /* A quarter or a month, where there is room to hover comfortably. */\n md: '[--cell:14px] [--gap:3px]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const activityCellVariants = cva('rounded-[3px]', {\n variants: {\n kind: {\n /* The bare square. Not the faintest step of the ramp - that is a value,\n * and this is the absence of one. */\n none: 'bg-soft',\n value: '',\n /* Outlined rather than filled: under way has no total to shade. */\n partial: 'border border-dashed border-accent/60',\n /* Drawn, because the column has to be seven tall, but never as data:\n * the caller did not ask about this date. */\n outside: 'bg-softer/40',\n },\n step: {\n 1: 'bg-heat-1',\n 2: 'bg-heat-2',\n 3: 'bg-heat-3',\n 4: 'bg-heat-4',\n 5: 'bg-heat-5',\n },\n },\n defaultVariants: { kind: 'none' },\n})\n\nexport interface ActivityHeatmapProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof activityHeatmapVariants> {\n /** What is known about each date. Dates with no entry are drawn as nothing\n * recorded, which is a different fact from a value of zero. */\n entries: ActivityEntry[]\n /** First date of the range, `YYYY-MM-DD`. */\n from: string\n /** Last date. */\n to: string\n /** The day a week starts on, Sunday-first like `Date#getUTCDay`. Monday by\n * default. It decides which row a date lands on, so it is stated rather than\n * guessed from a locale this cannot see. */\n weekStartsOn?: Weekday\n /** The ceiling the shades are measured against. Defaults to the largest\n * value present; state it to compare two grids by eye. */\n busiest?: number\n /** What the grid as a whole is, for a reader who cannot see it. */\n label: string\n /** What one square says. Given the cell, returns the sentence that becomes\n * its hover title and its accessible name - the caller owns it because only\n * the caller knows whether a value is hours, words or commits. */\n describe: (cell: Cell) => string\n /** Row labels, one per weekday in drawing order. Omitted entirely rather\n * than defaulted: a weekday name is a word in a language this cannot pick. */\n weekdayLabel?: (weekday: Weekday) => ReactNode\n}\n\nexport function ActivityHeatmap({\n entries,\n from,\n to,\n weekStartsOn = 1,\n busiest,\n label,\n describe,\n weekdayLabel,\n size,\n className,\n ...props\n}: ActivityHeatmapProps) {\n const columns = weeks(entries, { from, to, weekStartsOn, busiest })\n const rows = weekdayRows(weekStartsOn)\n\n return (\n <div className={cn(activityHeatmapVariants({ size }), className)} {...props}>\n <div className=\"flex gap-[var(--gap)]\" role=\"img\" aria-label={label}>\n {weekdayLabel !== undefined && (\n <div\n className=\"mr-1 flex flex-col gap-[var(--gap)] text-[10px] leading-[var(--cell)] text-faint\"\n aria-hidden\n >\n {rows.map((weekday) => (\n <span key={weekday} className=\"h-[var(--cell)]\">\n {weekdayLabel(weekday)}\n </span>\n ))}\n </div>\n )}\n\n {columns.map((column) => (\n <div key={column[0]!.date} className=\"flex flex-col gap-[var(--gap)]\">\n {column.map((cell) => (\n <span\n key={cell.date}\n title={describe(cell)}\n className={cn(\n 'size-[var(--cell)]',\n activityCellVariants({\n kind: cell.kind,\n step: cell.step as 1 | 2 | 3 | 4 | 5 | undefined,\n }),\n )}\n />\n ))}\n </div>\n ))}\n </div>\n </div>\n )\n}\n"
326
+ }
327
+ ]
328
+ },
329
+ {
330
+ "name": "activity-legend",
331
+ "type": "registry:ui",
332
+ "title": "Activity-legend",
333
+ "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
+ "dependencies": [
335
+ "dowel-ui@^0.24.0"
336
+ ],
337
+ "registryDependencies": [
338
+ "https://lacodda.github.io/dowel/r/activity-heatmap.json",
339
+ "https://lacodda.github.io/dowel/r/activity-weeks.json"
340
+ ],
341
+ "files": [
342
+ {
343
+ "path": "ui/activity-legend.tsx",
344
+ "target": "@ui/activity-legend.tsx",
345
+ "type": "registry:ui",
346
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { activityCellVariants } from './activity-heatmap'\nimport { STEPS } from './activity-weeks'\n\n/*\n * What the shades mean, in the same tokens the grid paints with.\n *\n * Separate from the grid because a caller showing three grids on one screen\n * wants one legend, and because the words in it are the product's.\n */\nexport interface ActivityLegendProps extends HTMLAttributes<HTMLDivElement> {\n /** The word at the faint end - \"less\", usually. */\n less: ReactNode\n /** The word at the dark end. */\n more: ReactNode\n /** What the darkest square stands for, in words: the scale is relative, and\n * without this the shades read as an absolute measure. Omitted when there is\n * no value in the grid to be busiest. */\n busiest?: ReactNode\n /** The other three meanings, each named. Omitted one at a time by a caller\n * whose data cannot produce that kind. */\n none?: ReactNode\n partial?: ReactNode\n}\n\nexport function ActivityLegend({\n less,\n more,\n busiest,\n none,\n partial,\n className,\n ...props\n}: ActivityLegendProps) {\n return (\n <div\n className={cn('flex flex-wrap items-center gap-x-5 gap-y-2 text-[11px] text-faint', className)}\n {...props}\n >\n <span className=\"flex items-center gap-1.5\">\n <span>{less}</span>\n {Array.from({ length: STEPS }, (_, index) => (\n <span\n key={index}\n className={cn('size-3', activityCellVariants({ kind: 'value', step: (index + 1) as 1 | 2 | 3 | 4 | 5 }))}\n />\n ))}\n <span>{more}</span>\n {busiest !== undefined && <span className=\"ml-1\">· {busiest}</span>}\n </span>\n\n {none !== undefined && (\n <span className=\"flex items-center gap-1.5\">\n <span className={cn('size-3', activityCellVariants({ kind: 'none' }))} />\n {none}\n </span>\n )}\n\n {partial !== undefined && (\n <span className=\"flex items-center gap-1.5\">\n <span className={cn('size-3', activityCellVariants({ kind: 'partial' }))} />\n {partial}\n </span>\n )}\n </div>\n )\n}\n"
347
+ }
348
+ ]
349
+ },
350
+ {
351
+ "name": "activity-weeks",
352
+ "type": "registry:ui",
353
+ "title": "Activity-weeks",
354
+ "description": "Split out for the reason `table-sort` and `track-segments` are - a product that draws this somewhere other than the DOM needs the arithmetic, not a component. One of the line's consumers draws it in a terminal.",
355
+ "dependencies": [],
356
+ "registryDependencies": [],
357
+ "files": [
358
+ {
359
+ "path": "ui/activity-weeks.tsx",
360
+ "target": "@ui/activity-weeks.tsx",
361
+ "type": "registry:ui",
362
+ "content": "/*\n * Laying a run of dates out in weeks, with no React in it.\n *\n * Split out for the reason `table-sort` and `track-segments` are - a product\n * that draws this somewhere other than the DOM needs the arithmetic, not a\n * component. One of the line's consumers draws it in a terminal.\n *\n * The rule the whole grid rests on, and the one worth stating loudest:\n * **a cell has four meanings and only one of them is a number.** Nothing\n * recorded, a value, a day still in progress, and a date outside the range\n * asked for are four different facts. A grid that paints \"nothing recorded\" as\n * the palest shade of \"a value\" tells the reader somebody did nothing on a day\n * nobody reported - which is a claim invented by the drawing.\n */\n\n/** What one square of the grid is. */\nexport type CellKind =\n /** No entry for this date. Not zero - no data at all. */\n | 'none'\n /** An entry with a figure behind it. */\n | 'value'\n /** Under way, with no total yet: today's row, a day still open. */\n | 'partial'\n /** A date the grid draws to keep its shape, outside the range asked for. */\n | 'outside'\n\n/** What the caller knows about one date. */\nexport interface ActivityEntry {\n /** `YYYY-MM-DD`. A label, never a moment: no zone shifts a date here. */\n date: string\n /** The figure. `null` for a day under way, which has no total yet. */\n value: number | null\n}\n\nexport interface Cell {\n date: string\n kind: CellKind\n value: number | null\n /** Which of the five steps this lands on, 1-5; `null` unless `kind` is\n * `value`. Discrete rather than continuous, because five shades can be told\n * apart and matched against a legend where a smooth gradient can only say\n * \"more\" and \"less\". */\n step: number | null\n /** Saturday or Sunday, read from the date itself.\n *\n * Only the weekend, never \"a day off\": which days someone actually rests is\n * a calendar this does not have, and guessing would mark an ordinary\n * Saturday shift as unusual. */\n weekend: boolean\n}\n\n/** Five steps, and the reason there are five: a legend a reader can count. */\nexport const STEPS = 5\n\n/** Sunday-first, because that is what `Date#getUTCDay` counts from. */\nexport type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\nconst DAY = 86_400_000\n\nfunction parse(date: string): number {\n return Date.parse(`${date}T00:00:00Z`)\n}\n\nfunction format(time: number): string {\n return new Date(time).toISOString().slice(0, 10)\n}\n\n/** Every date from `from` to `to` inclusive, as `YYYY-MM-DD`.\n *\n * Stepped through UTC so no local zone can shift a day: a date here is a\n * label, not a moment. */\nexport function datesBetween(from: string, to: string): string[] {\n const start = parse(from)\n const end = parse(to)\n if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return []\n\n const dates: string[] = []\n for (let time = start; time <= end; time += DAY) dates.push(format(time))\n return dates\n}\n\nexport function isWeekend(date: string): boolean {\n const day = new Date(parse(date)).getUTCDay()\n return day === 0 || day === 6\n}\n\nexport interface WeeksOptions {\n /** First date of the range the caller asked for. */\n from: string\n /** Last date. */\n to: string\n /** The day a week starts on. Monday in most of the world, Sunday in the US -\n * and it changes which column a date lands in, so it is stated rather than\n * guessed from a locale the grid cannot see. */\n weekStartsOn?: Weekday\n /** The ceiling the steps are measured against. Defaults to the largest value\n * present.\n *\n * Shared rather than per-column, so two grids can be compared: scaled to\n * itself, a quiet month and a heavy one each get their own darkest square,\n * which is the one thing a heatmap is for. */\n busiest?: number\n}\n\n/**\n * The range as columns of weeks, each column seven cells from the top.\n *\n * Columns rather than rows because that is the shape the grid is read in: a\n * week is a column, a weekday is a row, and time runs left to right.\n *\n * The first and last columns are padded to seven with `outside` cells, so\n * every column is the same height and a weekday stays on its own row. They are\n * not \"no data\" - the caller never asked about them - and drawing them as\n * empty squares would add days to the range that the reader did not request.\n */\nexport function weeks(entries: ActivityEntry[], options: WeeksOptions): Cell[][] {\n const { from, to, weekStartsOn = 1 } = options\n const dates = datesBetween(from, to)\n if (dates.length === 0) return []\n\n const byDate = new Map(entries.map((entry) => [entry.date, entry]))\n\n const values = entries\n .map((entry) => entry.value)\n .filter((value): value is number => value !== null && Number.isFinite(value))\n const busiest = options.busiest ?? (values.length > 0 ? Math.max(...values) : 0)\n\n const cellFor = (date: string): Cell => {\n const entry = byDate.get(date)\n const weekend = isWeekend(date)\n\n if (entry === undefined) return { date, kind: 'none', value: null, step: null, weekend }\n if (entry.value === null) return { date, kind: 'partial', value: null, step: null, weekend }\n\n return { date, kind: 'value', value: entry.value, step: stepFor(entry.value, busiest), weekend }\n }\n\n const outside = (date: string): Cell => ({\n date,\n kind: 'outside',\n value: null,\n step: null,\n weekend: isWeekend(date),\n })\n\n /* Where the first date sits in its own week, so the run starts on the right\n * row rather than at the top of the first column. */\n const columnOf = (date: string) => (new Date(parse(date)).getUTCDay() - weekStartsOn + 7) % 7\n\n const columns: Cell[][] = []\n let column: Cell[] = []\n\n // Pad the head, back-dating the squares so each one is the date it stands\n // for - a reader hovering the first column should not find a blank.\n const lead = columnOf(dates[0]!)\n for (let i = lead; i > 0; i--) column.push(outside(format(parse(dates[0]!) - i * DAY)))\n\n for (const date of dates) {\n column.push(cellFor(date))\n if (column.length === 7) {\n columns.push(column)\n column = []\n }\n }\n\n if (column.length > 0) {\n const last = parse(dates.at(-1)!)\n for (let i = 1; column.length < 7; i++) column.push(outside(format(last + i * DAY)))\n columns.push(column)\n }\n\n return columns\n}\n\n/**\n * Which step a value lands on, 1 to `STEPS`.\n *\n * The floor is 1 rather than 0: any value at all is a value, and a square\n * indistinguishable from an empty one would file a twenty-minute day under\n * \"nothing recorded\" - the confusion this module exists to prevent. A value of\n * zero is the exception, and it is the caller's own statement: zero measured is\n * not the same as nothing measured, and it still earns the faintest step.\n */\nexport function stepFor(value: number, busiest: number): number {\n /* No ceiling to measure against. That happens two ways and they mean\n * opposite things: a caller who stated no `busiest` and whose only values\n * are zero (every day measured, every day empty - the faintest step, because\n * that is what they are), or a caller who stated a ceiling of zero while\n * sending a real figure (the ceiling is wrong, and the figure is all the\n * grid has - the darkest, so it is not hidden). */\n if (!(busiest > 0)) return value > 0 ? STEPS : 1\n const share = Math.min(1, Math.max(0, value / busiest))\n return Math.max(1, Math.ceil(share * STEPS))\n}\n\n/** The weekday rows, in the order the grid draws them, as offsets from Sunday.\n *\n * Returned rather than assumed so a caller labelling the rows and a grid\n * drawing them cannot disagree about which row is Monday. */\nexport function weekdayRows(weekStartsOn: Weekday = 1): Weekday[] {\n return [0, 1, 2, 3, 4, 5, 6].map((offset) => ((weekStartsOn + offset) % 7) as Weekday)\n}\n"
363
+ }
364
+ ]
365
+ },
308
366
  {
309
367
  "name": "alert",
310
368
  "type": "registry:ui",
@@ -312,7 +370,7 @@
312
370
  "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.",
313
371
  "dependencies": [
314
372
  "class-variance-authority",
315
- "dowel-ui@^0.22.0"
373
+ "dowel-ui@^0.24.0"
316
374
  ],
317
375
  "registryDependencies": [],
318
376
  "files": [
@@ -331,7 +389,7 @@
331
389
  "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.",
332
390
  "dependencies": [
333
391
  "class-variance-authority",
334
- "dowel-ui@^0.22.0"
392
+ "dowel-ui@^0.24.0"
335
393
  ],
336
394
  "registryDependencies": [],
337
395
  "files": [
@@ -350,7 +408,7 @@
350
408
  "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.",
351
409
  "dependencies": [
352
410
  "class-variance-authority",
353
- "dowel-ui@^0.22.0"
411
+ "dowel-ui@^0.24.0"
354
412
  ],
355
413
  "registryDependencies": [],
356
414
  "files": [
@@ -362,6 +420,25 @@
362
420
  }
363
421
  ]
364
422
  },
423
+ {
424
+ "name": "bar-chart",
425
+ "type": "registry:ui",
426
+ "title": "Bar-chart",
427
+ "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
+ "dependencies": [
429
+ "class-variance-authority",
430
+ "dowel-ui@^0.24.0"
431
+ ],
432
+ "registryDependencies": [],
433
+ "files": [
434
+ {
435
+ "path": "ui/bar-chart.tsx",
436
+ "target": "@ui/bar-chart.tsx",
437
+ "type": "registry:ui",
438
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Columns over a baseline: one period, one bar.\n *\n * Bars rather than a line, and the distinction is the data's not the\n * drawing's: a line says the value exists between the points, a column says\n * each period is its own sum. Hours worked in a week is a sum - there is no\n * \"Wednesday afternoon\" reading between two weeks - so it is a column.\n *\n * **The chart owns its height in pixels.** That is not a style choice, it is\n * the defect this component exists to prevent. The first consumer drew its\n * bars as a percentage of the parent, inside a flex row sized from its own\n * content: the child had no base to be a percentage of, every bar computed to\n * zero, and the chart shipped as a row of bare axis labels. No test and no API\n * check could see it - the owner found it by looking, and it cost a patch\n * release. Here the plot is a stated number of pixels and a bar is a\n * percentage of *that*.\n *\n * **An absent period is not a short one.** A week with nothing recorded and a\n * week of twenty minutes are different facts, and a bar of no height says\n * neither - it reads as a bar that failed to render. So a gap keeps its place\n * in the row and is drawn as a mark of its own.\n *\n * Interaction is one `title` per column rather than a tooltip layer: this is a\n * small chart that sits inside a panel, and the numbers it holds also belong\n * in the list beside it. A reader who needs them exactly should not have to\n * hover to find out.\n */\n\nexport const barChartVariants = cva('flex items-end gap-1 border-b border-chart-axis', {\n variants: {\n size: {\n /* Inside a panel, beside other things. */\n sm: '[--plot:72px]',\n /* On its own, where the shape is the subject. */\n md: '[--plot:112px]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const barVariants = cva('mx-auto w-3/5 max-w-6 rounded-t-[4px]', {\n variants: {\n tone: {\n accent: 'bg-accent',\n /* For a bar that is one of several series, or one the caller is\n * highlighting against the rest. */\n series: 'bg-series-1',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n /* The rest of the field, when one bar is the story: emphasis is the\n * most underused form in a chart of eight equal colours. */\n muted: 'bg-line-2',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface BarDatum {\n /** Distinguishes this column from its neighbours. */\n key: string\n /** The period's own sum. `null` is a period with nothing recorded, which is\n * not the same as a sum of zero and is not drawn as a bar. */\n value: number | null\n /** What goes under the column. Kept short - these labels sit at a width the\n * chart does not control. */\n label: ReactNode\n tone?: NonNullable<VariantProps<typeof barVariants>['tone']>\n /** What the column says, for a reader hovering it and for a screen reader.\n * Required per bar, because a rectangle announces nothing. */\n title: string\n}\n\nexport interface BarChartProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof barChartVariants> {\n bars: BarDatum[]\n /** The top of the scale. Defaults to the tallest bar present; state it to\n * compare two charts, or to hold a scale still while the data moves. */\n max?: number\n /** What the whole chart is, in words. */\n label: string\n}\n\n/** The shortest a bar may be drawn, as a percentage of the plot.\n *\n * A twenty-minute week against a forty-hour one is half a percent, which\n * rounds to nothing: the period was recorded and would simply not be there. */\nconst MIN_BAR = 3\n\nexport function BarChart({ bars, max, label, size, className, ...props }: BarChartProps) {\n const values = bars.map((bar) => bar.value).filter((value): value is number => value !== null)\n /* A ceiling of at least one, so a chart of nothing but zeroes divides\n * safely and draws a row of floors rather than nothing at all. */\n const ceiling = Math.max(max ?? 0, ...values, 1)\n\n return (\n <div className={cn(barChartVariants({ size }), className)} role=\"img\" aria-label={label} {...props}>\n {bars.map((bar) => (\n <div key={bar.key} className=\"flex min-w-0 flex-1 flex-col items-center gap-1.5\">\n {/* The track carries the height itself. See the note above: a\n * percentage against a parent that has no height of its own\n * resolves to zero, and the chart disappears without failing. */}\n <div className=\"flex h-[var(--plot)] w-full items-end\" title={bar.title}>\n {bar.value === null ? (\n /* A gap, drawn as one. A bar of no height is indistinguishable\n * from a bar that did not render, and closing the gap up would\n * turn an absence into continuity - the one thing a trend must\n * not do. */\n <div className=\"mx-auto h-1 w-3/5 max-w-6 rounded-t-[4px] border-x border-t border-line-2\" />\n ) : (\n <div\n className={cn(barVariants({ tone: bar.tone }))}\n style={{ height: `${Math.max((bar.value / ceiling) * 100, MIN_BAR)}%` }}\n />\n )}\n </div>\n <span className=\"w-full truncate text-center text-[10px] text-faint tabular-nums\">\n {bar.label}\n </span>\n </div>\n ))}\n </div>\n )\n}\n\n/*\n * A line across the plot, at a value on the same scale.\n *\n * For the number every bar is read against - a median, a target, an agreed\n * norm. A chart without its baseline invites the reader to invent one, and the\n * one they invent is usually the tallest bar.\n *\n * Drawn as a solid hairline rather than a dash: a dashed rule reads as\n * \"projected\" or \"threshold\" when it is neither, and the doctrine is explicit\n * that grid and axis lines are solid.\n */\nexport interface BaselineProps extends HTMLAttributes<HTMLDivElement> {\n /** Where it sits, on the same scale as the bars. */\n value: number\n /** The same ceiling the chart is drawn against. */\n max: number\n /** What the line is, beside it. */\n children?: ReactNode\n}\n\nexport function Baseline({ value, max, children, className, ...props }: BaselineProps) {\n if (!(max > 0) || value < 0 || value > max) return null\n\n return (\n <div\n className={cn('pointer-events-none absolute inset-x-0 flex items-center gap-2', className)}\n style={{ bottom: `${(value / max) * 100}%` }}\n {...props}\n >\n {/* The rule stops short of the label rather than running under it: a\n * hairline crossing its own caption reads as a strikethrough. */}\n <div className=\"h-px flex-1 bg-chart-grid\" />\n {children !== undefined && (\n <span className=\"shrink-0 text-[10px] leading-none text-faint tabular-nums\">{children}</span>\n )}\n </div>\n )\n}\n\n/*\n * The box a chart and its baseline share.\n *\n * Two things it does, and both are the component's job rather than the\n * caller's. It establishes the positioning context the baseline needs - left\n * to the caller that is a `relative` remembered or forgotten, and forgotten it\n * puts the rule at the bottom of the page. And it keeps a gutter on the right\n * for the baseline's label, which otherwise sits on top of the last columns:\n * the label is outside the plot, so the plot has to end before it starts.\n */\nexport interface ChartFrameProps extends HTMLAttributes<HTMLDivElement> {\n /** Room on the right for the baseline's label. Omit it where there is no\n * baseline, or where the label is short enough to live in the panel's own\n * padding. */\n gutter?: boolean\n children: ReactNode\n}\n\nexport function ChartFrame({ gutter = true, className, ...props }: ChartFrameProps) {\n return <div className={cn('relative', gutter && 'pr-16', className)} {...props} />\n}\n"
439
+ }
440
+ ]
441
+ },
365
442
  {
366
443
  "name": "button",
367
444
  "type": "registry:ui",
@@ -370,7 +447,7 @@
370
447
  "dependencies": [
371
448
  "@base-ui/react",
372
449
  "class-variance-authority",
373
- "dowel-ui@^0.22.0"
450
+ "dowel-ui@^0.24.0"
374
451
  ],
375
452
  "registryDependencies": [],
376
453
  "files": [
@@ -404,7 +481,7 @@
404
481
  "title": "Calendar",
405
482
  "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.",
406
483
  "dependencies": [
407
- "dowel-ui@^0.22.0"
484
+ "dowel-ui@^0.24.0"
408
485
  ],
409
486
  "registryDependencies": [
410
487
  "https://lacodda.github.io/dowel/r/calendar-math.json"
@@ -425,7 +502,7 @@
425
502
  "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.",
426
503
  "dependencies": [
427
504
  "@base-ui/react",
428
- "dowel-ui@^0.22.0"
505
+ "dowel-ui@^0.24.0"
429
506
  ],
430
507
  "registryDependencies": [],
431
508
  "files": [
@@ -444,7 +521,7 @@
444
521
  "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.",
445
522
  "dependencies": [
446
523
  "class-variance-authority",
447
- "dowel-ui@^0.22.0"
524
+ "dowel-ui@^0.24.0"
448
525
  ],
449
526
  "registryDependencies": [],
450
527
  "files": [
@@ -462,7 +539,7 @@
462
539
  "title": "Color-field",
463
540
  "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.",
464
541
  "dependencies": [
465
- "dowel-ui@^0.22.0"
542
+ "dowel-ui@^0.24.0"
466
543
  ],
467
544
  "registryDependencies": [
468
545
  "https://lacodda.github.io/dowel/r/input.json"
@@ -484,7 +561,7 @@
484
561
  "dependencies": [
485
562
  "@base-ui/react",
486
563
  "class-variance-authority",
487
- "dowel-ui@^0.22.0"
564
+ "dowel-ui@^0.24.0"
488
565
  ],
489
566
  "registryDependencies": [
490
567
  "https://lacodda.github.io/dowel/r/input.json",
@@ -507,7 +584,7 @@
507
584
  "dependencies": [
508
585
  "@base-ui/react",
509
586
  "class-variance-authority",
510
- "dowel-ui@^0.22.0"
587
+ "dowel-ui@^0.24.0"
511
588
  ],
512
589
  "registryDependencies": [
513
590
  "https://lacodda.github.io/dowel/r/combobox.json",
@@ -530,7 +607,7 @@
530
607
  "dependencies": [
531
608
  "@base-ui/react",
532
609
  "class-variance-authority",
533
- "dowel-ui@^0.22.0"
610
+ "dowel-ui@^0.24.0"
534
611
  ],
535
612
  "registryDependencies": [],
536
613
  "files": [
@@ -549,7 +626,7 @@
549
626
  "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>`.",
550
627
  "dependencies": [
551
628
  "@base-ui/react",
552
- "dowel-ui@^0.22.0"
629
+ "dowel-ui@^0.24.0"
553
630
  ],
554
631
  "registryDependencies": [
555
632
  "https://lacodda.github.io/dowel/r/menu.json"
@@ -569,7 +646,7 @@
569
646
  "title": "Copyable",
570
647
  "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.",
571
648
  "dependencies": [
572
- "dowel-ui@^0.22.0"
649
+ "dowel-ui@^0.24.0"
573
650
  ],
574
651
  "registryDependencies": [],
575
652
  "files": [
@@ -587,7 +664,7 @@
587
664
  "title": "Date-picker",
588
665
  "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.",
589
666
  "dependencies": [
590
- "dowel-ui@^0.22.0"
667
+ "dowel-ui@^0.24.0"
591
668
  ],
592
669
  "registryDependencies": [
593
670
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -610,7 +687,7 @@
610
687
  "title": "Date-range-picker",
611
688
  "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.",
612
689
  "dependencies": [
613
- "dowel-ui@^0.22.0"
690
+ "dowel-ui@^0.24.0"
614
691
  ],
615
692
  "registryDependencies": [
616
693
  "https://lacodda.github.io/dowel/r/calendar.json",
@@ -635,7 +712,7 @@
635
712
  "dependencies": [
636
713
  "@base-ui/react",
637
714
  "class-variance-authority",
638
- "dowel-ui@^0.22.0"
715
+ "dowel-ui@^0.24.0"
639
716
  ],
640
717
  "registryDependencies": [],
641
718
  "files": [
@@ -655,7 +732,7 @@
655
732
  "dependencies": [
656
733
  "@base-ui/react",
657
734
  "class-variance-authority",
658
- "dowel-ui@^0.22.0"
735
+ "dowel-ui@^0.24.0"
659
736
  ],
660
737
  "registryDependencies": [],
661
738
  "files": [
@@ -673,7 +750,7 @@
673
750
  "title": "Duration-field",
674
751
  "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.",
675
752
  "dependencies": [
676
- "dowel-ui@^0.22.0"
753
+ "dowel-ui@^0.24.0"
677
754
  ],
678
755
  "registryDependencies": [
679
756
  "https://lacodda.github.io/dowel/r/input.json"
@@ -687,6 +764,44 @@
687
764
  }
688
765
  ]
689
766
  },
767
+ {
768
+ "name": "empty-state",
769
+ "type": "registry:ui",
770
+ "title": "Empty-state",
771
+ "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
+ "dependencies": [
773
+ "class-variance-authority",
774
+ "dowel-ui@^0.24.0"
775
+ ],
776
+ "registryDependencies": [],
777
+ "files": [
778
+ {
779
+ "path": "ui/empty-state.tsx",
780
+ "target": "@ui/empty-state.tsx",
781
+ "type": "registry:ui",
782
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * What a screen says when there is nothing on it.\n *\n * Three kinds of nothing, and a product that draws the same panel for all\n * three is telling the reader the wrong thing twice:\n *\n * **empty** - there is nothing here yet, and that is normal. The panel says\n * what would be here and offers the one action that makes it appear.\n *\n * **filtered** - there is plenty here, just none of it matching. The way out\n * is to widen the filter, not to create anything.\n *\n * **error** - it could not be fetched. Nothing is missing; something failed,\n * and the way out is to try again.\n *\n * They differ in what the reader should do next, which is exactly what an\n * empty screen is for, so they are a variant rather than three components.\n *\n * `action` is the whole point of the component: **an empty screen with no way\n * out is a dead end.** It is optional in the type because a panel inside a\n * larger screen can borrow the way out from its surroundings - but a full-page\n * empty state without one is a bug the reader cannot report.\n *\n * The mark is the line's hexagon, drawn in the current text colour rather than\n * a product's accent. A full-strength logo in an empty panel shouts; this is a\n * watermark, and it is `aria-hidden` because it says nothing a reader needs.\n */\n\nexport const emptyStateVariants = cva(\n 'flex flex-col items-center justify-center gap-3 rounded-xl p-8 text-center',\n {\n variants: {\n variant: {\n empty: 'border border-dashed border-line-2',\n filtered: 'border border-dashed border-line-2',\n // Solid rather than dashed: a dashed border reads as a placeholder for\n // something that belongs there, and a failure is not that.\n error: 'border border-bad/40 bg-bad-soft/30',\n },\n },\n defaultVariants: { variant: 'empty' },\n },\n)\n\n/** The line's hexagon, at whatever size the caller asks for.\n *\n * Inlined rather than fetched: it is decorative, so it should not cost a\n * request, and it has to take the theme's colour - a file could not. */\nexport function EmptyMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n </svg>\n )\n}\n\n/** The same hexagon, cut by a slash. For a filter that matched nothing: the\n * shape is there, the contents are not. */\nexport function FilteredMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polygon\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n />\n <line x1=\"22\" y1=\"78\" x2=\"78\" y2=\"22\" stroke=\"currentColor\" strokeWidth={6} strokeLinecap=\"round\" />\n </svg>\n )\n}\n\n/** A hexagon with a corner broken out of it. For something that failed. */\nexport function ErrorMark({ className }: { className?: string }) {\n return (\n <svg viewBox=\"0 0 100 100\" aria-hidden className={cn('size-14', className)}>\n <polyline\n points=\"50,5 89,27.5 89,72.5 50,95 11,72.5 11,27.5 50,5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={6}\n strokeLinejoin=\"round\"\n strokeLinecap=\"round\"\n // The gap is the break: the outline is drawn as a line rather than a\n // closed shape so one edge can be missing.\n strokeDasharray=\"150 34\"\n />\n </svg>\n )\n}\n\nexport interface EmptyStateProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'title'>,\n VariantProps<typeof emptyStateVariants> {\n /** What is not here, in the product's words. */\n title: ReactNode\n /** Why, or what to do about it. */\n body?: ReactNode\n /** The one thing worth doing here. An empty screen with no way out is a\n * dead end. */\n action?: ReactNode\n /** Something other than the default mark - a product's own illustration. */\n mark?: ReactNode\n}\n\nexport function EmptyState({\n variant = 'empty',\n title,\n body,\n action,\n mark,\n className,\n ...props\n}: EmptyStateProps) {\n const defaultMark =\n variant === 'error' ? (\n <ErrorMark className=\"text-bad/60\" />\n ) : variant === 'filtered' ? (\n <FilteredMark className=\"text-line-2\" />\n ) : (\n <EmptyMark className=\"text-line-2\" />\n )\n\n return (\n <div className={cn(emptyStateVariants({ variant }), className)} {...props}>\n {mark ?? defaultMark}\n <div>\n <p className={cn('font-medium', variant === 'error' && 'text-bad')}>{title}</p>\n {body !== undefined && <p className=\"mt-1 text-sm text-dim\">{body}</p>}\n </div>\n {action}\n </div>\n )\n}\n"
783
+ }
784
+ ]
785
+ },
786
+ {
787
+ "name": "error-boundary",
788
+ "type": "registry:ui",
789
+ "title": "Error-boundary",
790
+ "description": "A class component, and the only one in the set - not a style choice: React gives no hook for catching a render error, and `componentDidCatch` exists nowhere else. Anything that claims otherwise catches events, not renders.",
791
+ "dependencies": [],
792
+ "registryDependencies": [
793
+ "https://lacodda.github.io/dowel/r/button.json",
794
+ "https://lacodda.github.io/dowel/r/empty-state.json"
795
+ ],
796
+ "files": [
797
+ {
798
+ "path": "ui/error-boundary.tsx",
799
+ "target": "@ui/error-boundary.tsx",
800
+ "type": "registry:ui",
801
+ "content": "import { Component, type ErrorInfo, type ReactNode } from 'react'\nimport { Button } from './button'\nimport { EmptyState } from './empty-state'\n\n/*\n * The screen that appears instead of a crash.\n *\n * A class component, and the only one in the set - not a style choice: React\n * gives no hook for catching a render error, and `componentDidCatch` exists\n * nowhere else. Anything that claims otherwise catches events, not renders.\n *\n * What it is for is narrow and worth stating, because products reach for it as\n * a general error handler and it is not one: it catches errors **thrown while\n * rendering**, below itself. A failed fetch is not that - it is a value the\n * component receives and shows, which is `QueryState`. An error in an event\n * handler is not that either; nothing catches those but the handler.\n *\n * So this is the last line: something that should never have thrown did, and\n * the alternative is a white page with the product's own name at the top.\n *\n * The fallback is deliberately plain and deliberately says the message. The\n * reader cannot fix it, but the reader is who files it - and a screen that\n * hides the one string worth quoting turns a bug report into a guess. It is\n * folded away behind a summary, because it is evidence rather than an\n * instruction.\n */\n\nexport interface ErrorBoundaryProps {\n children: ReactNode\n /** Draw something else instead of the default screen.\n *\n * Given the error and a way to clear it, because a fallback that cannot\n * retry is a dead end with extra steps. */\n fallback?: (error: Error, reset: () => void) => ReactNode\n /** Change this to clear the error - a route, usually.\n *\n * Without it a boundary that has caught once stays caught: the reader\n * navigates away from the broken screen and the crash follows them, because\n * nothing told the boundary the reason had gone. */\n resetKey?: unknown\n /** Somewhere to send it. The product owns whether that is a log, a file or a\n * service - the boundary only knows it happened. */\n onError?: (error: Error, info: ErrorInfo) => void\n /** The words on the default screen. Required, and not defaulted: they are\n * the only text here, and English inside a primitive is text no product can\n * translate. */\n labels: ErrorBoundaryLabels\n}\n\nexport interface ErrorBoundaryLabels {\n /** \"Something went wrong.\" */\n title: string\n /** What the reader can do about it. */\n body: string\n /** The summary that unfolds the message, e.g. \"Details\". */\n details: string\n /** The retry button, e.g. \"Try again\". */\n retry: string\n}\n\ninterface State {\n error: Error | null\n}\n\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, State> {\n state: State = { error: null }\n\n static getDerivedStateFromError(error: Error): State {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo): void {\n this.props.onError?.(error, info)\n }\n\n componentDidUpdate(previous: ErrorBoundaryProps): void {\n // Clearing on a changed key, rather than on any re-render: a boundary that\n // resets whenever its parent renders re-runs the throwing child\n // immediately, and the screen flickers between the crash and the fallback\n // for as long as the cause is there.\n if (this.state.error !== null && previous.resetKey !== this.props.resetKey) {\n this.setState({ error: null })\n }\n }\n\n private reset = (): void => {\n this.setState({ error: null })\n }\n\n render(): ReactNode {\n const { error } = this.state\n if (error === null) return this.props.children\n\n const { fallback, labels } = this.props\n if (fallback) return fallback(error, this.reset)\n\n return (\n <EmptyState\n variant=\"error\"\n title={labels.title}\n body={labels.body}\n action={\n <div className=\"flex w-full flex-col items-center gap-3\">\n <details className=\"w-full text-left\">\n <summary className=\"cursor-pointer text-xs text-faint\">{labels.details}</summary>\n {/* Selectable and monospaced, because its job is to be copied\n * into a bug report verbatim. */}\n <pre className=\"mt-2 max-h-40 overflow-auto rounded-md bg-softer p-3 font-mono text-xs text-dim\">\n {error.message}\n </pre>\n </details>\n <Button variant=\"primary\" onClick={this.reset}>\n {labels.retry}\n </Button>\n </div>\n }\n />\n )\n }\n}\n"
802
+ }
803
+ ]
804
+ },
690
805
  {
691
806
  "name": "field",
692
807
  "type": "registry:ui",
@@ -694,7 +809,7 @@
694
809
  "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.",
695
810
  "dependencies": [
696
811
  "@base-ui/react",
697
- "dowel-ui@^0.22.0"
812
+ "dowel-ui@^0.24.0"
698
813
  ],
699
814
  "registryDependencies": [],
700
815
  "files": [
@@ -712,7 +827,7 @@
712
827
  "title": "File-drop",
713
828
  "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.",
714
829
  "dependencies": [
715
- "dowel-ui@^0.22.0"
830
+ "dowel-ui@^0.24.0"
716
831
  ],
717
832
  "registryDependencies": [],
718
833
  "files": [
@@ -730,7 +845,7 @@
730
845
  "title": "Input",
731
846
  "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.",
732
847
  "dependencies": [
733
- "dowel-ui@^0.22.0"
848
+ "dowel-ui@^0.24.0"
734
849
  ],
735
850
  "registryDependencies": [],
736
851
  "files": [
@@ -748,7 +863,7 @@
748
863
  "title": "Kbd",
749
864
  "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.",
750
865
  "dependencies": [
751
- "dowel-ui@^0.22.0"
866
+ "dowel-ui@^0.24.0"
752
867
  ],
753
868
  "registryDependencies": [],
754
869
  "files": [
@@ -767,7 +882,7 @@
767
882
  "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.",
768
883
  "dependencies": [
769
884
  "class-variance-authority",
770
- "dowel-ui@^0.22.0"
885
+ "dowel-ui@^0.24.0"
771
886
  ],
772
887
  "registryDependencies": [],
773
888
  "files": [
@@ -779,6 +894,43 @@
779
894
  }
780
895
  ]
781
896
  },
897
+ {
898
+ "name": "line-chart",
899
+ "type": "registry:ui",
900
+ "title": "Line-chart",
901
+ "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
+ "dependencies": [
903
+ "class-variance-authority",
904
+ "dowel-ui@^0.24.0"
905
+ ],
906
+ "registryDependencies": [
907
+ "https://lacodda.github.io/dowel/r/line-scale.json"
908
+ ],
909
+ "files": [
910
+ {
911
+ "path": "ui/line-chart.tsx",
912
+ "target": "@ui/line-chart.tsx",
913
+ "type": "registry:ui",
914
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { boundsOf, pathOf, runs, ticksFor, yOf, type Bounds, type Point } from './line-scale'\n\n/*\n * A quantity over time, where it continues between the readings.\n *\n * The distinction against its neighbours is the data's, not the drawing's. A\n * column says each period is its own sum - hours worked in a week, and there\n * is no Wednesday-afternoon figure between two weeks. A line says the value\n * existed the whole time and was sampled: an account balance, a price, a\n * temperature. Drawing a sum as a line claims readings nobody took; drawing a\n * level as columns throws away the thing being watched.\n *\n * Sparkline is the same shape with the axes taken away, for beside a figure.\n * This one has them, because a balance without a scale is a squiggle.\n *\n * **A hole breaks the line.** Interpolating across invents a reading; closing\n * the gap up moves every later point and makes the axis lie about when things\n * happened. Both are quieter than the truth, which is why the truth has to be\n * drawn deliberately.\n *\n * **The floor is not zero unless the caller says so**, and that is a departure\n * from BarChart on purpose. A bar's length is the quantity, so its baseline\n * has to be zero. A line's subject is change, and a balance between 4,900 and\n * 5,100 on a zero-based axis is a flat rule. The ticks are what keep this\n * honest: they say where the bottom is.\n *\n * The plot owns its height in pixels, for the reason BarChart does - a\n * percentage against a parent with no height of its own resolves to zero, and\n * the chart disappears without failing.\n */\n\nexport const lineChartVariants = cva('relative w-full', {\n variants: {\n size: {\n sm: '[--plot:96px]',\n md: '[--plot:160px]',\n },\n /* Room on the right for the tick labels. Drawn inside the plot they sit on\n * top of whatever the line is doing there - found by looking, with `$5,200`\n * crossed out by its own series. A gutter costs a little width and cannot\n * collide. */\n gutter: {\n true: 'pr-12',\n false: '',\n },\n },\n defaultVariants: { size: 'md', gutter: true },\n})\n\nexport const lineVariants = cva('fill-none', {\n variants: {\n tone: {\n accent: 'stroke-accent',\n series: 'stroke-series-1',\n good: 'stroke-good',\n bad: 'stroke-bad',\n muted: 'stroke-dim',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface LineChartProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof lineChartVariants>,\n VariantProps<typeof lineVariants> {\n /** The readings, in order. `value: null` is a measurement not taken. */\n points: Point[]\n /** Override any edge of the plot. `min: 0` for a count, where zero is the\n * truth rather than a flattening. */\n bounds?: Partial<Bounds>\n /** What the chart as a whole says, for a reader who cannot see it. */\n label: string\n /** How many value ticks to aim for. They land on round numbers, so the count\n * is a wish rather than a promise. `0` draws none. */\n ticks?: number\n /** Turns a tick into its label. Without it the number is drawn as it is -\n * which is right for a count and wrong for money or a duration. */\n formatTick?: (value: number) => string\n /** The labels under the axis - usually the first and last reading. Two or\n * three, not one per point: the axis is not a place for a list. */\n footer?: ReactNode\n}\n\nexport function LineChart({\n points,\n bounds: stated,\n label,\n ticks = 4,\n formatTick,\n footer,\n tone,\n size,\n className,\n ...props\n}: LineChartProps) {\n const bounds = boundsOf(points, stated)\n\n /* Nothing measured is not a chart of zeroes: an empty plot with its axis is\n * the honest drawing, and the caller says so in words beside it. */\n if (bounds === null) {\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: false }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"h-[var(--plot)] w-full border-b border-chart-axis\" />\n </div>\n )\n }\n\n const drawn = runs(points, bounds)\n const rules = ticks > 0 ? ticksFor(bounds, ticks) : []\n\n return (\n <div\n className={cn(lineChartVariants({ size, gutter: rules.length > 0 }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <div className=\"relative h-[var(--plot)] w-full border-b border-chart-axis\">\n {rules.map((tick) => {\n const y = yOf(tick, bounds)\n if (y === null) return null\n return (\n <div key={tick} className=\"pointer-events-none absolute inset-x-0\" style={{ top: `${y}%` }}>\n {/* Solid hairline: a dashed rule reads as a threshold or a\n * projection, and this is neither. */}\n <div className=\"h-px w-full bg-chart-grid\" />\n {/* Outside the plot, in the gutter the variant reserves: a label\n * drawn over the series is a label crossed out by it. */}\n <span className=\"absolute -top-1.5 left-full pl-1.5 text-[10px] leading-none text-faint tabular-nums\">\n {formatTick ? formatTick(tick) : tick}\n </span>\n </div>\n )\n })}\n\n {/* `preserveAspectRatio=\"none\"` is what lets the plot be a box of the\n * caller's shape while the maths stays in percentages: x and y scale\n * independently, which would bend a shape but cannot bend a line\n * whose every segment is straight. The stroke is drawn in absolute\n * units so it does not stretch with the box. */}\n <svg\n viewBox=\"0 0 100 100\"\n preserveAspectRatio=\"none\"\n className=\"absolute inset-0 h-full w-full overflow-visible\"\n aria-hidden\n >\n {drawn.map((run) => (\n <path\n key={run[0]!.at}\n d={pathOf(run)}\n vectorEffect=\"non-scaling-stroke\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className={cn(lineVariants({ tone }))}\n />\n ))}\n </svg>\n </div>\n\n {footer !== undefined && (\n <div className=\"mt-1 flex justify-between text-[10px] text-faint tabular-nums\">{footer}</div>\n )}\n </div>\n )\n}\n"
915
+ }
916
+ ]
917
+ },
918
+ {
919
+ "name": "line-scale",
920
+ "type": "registry:ui",
921
+ "title": "Line-scale",
922
+ "description": "Split out like `track-segments` and `activity-weeks`: a product labelling its own points, or checking its own domain sums, should not import a component to get at the numbers.",
923
+ "dependencies": [],
924
+ "registryDependencies": [],
925
+ "files": [
926
+ {
927
+ "path": "ui/line-scale.tsx",
928
+ "target": "@ui/line-scale.tsx",
929
+ "type": "registry:ui",
930
+ "content": "/*\n * The arithmetic behind LineChart, with no React in it.\n *\n * Split out like `track-segments` and `activity-weeks`: a product labelling\n * its own points, or checking its own domain sums, should not import a\n * component to get at the numbers.\n *\n * Two things live here and neither is obvious. Turning a series with holes in\n * it into drawable runs, and choosing the ticks on an axis - which is a\n * question about what reads as a round number, not about dividing a range into\n * equal parts.\n */\n\n/** One reading. `value: null` is a measurement that was not taken - which is\n * not a reading of zero, and not a reason to move the ones after it. */\nexport interface Point {\n /** Position along the axis. Same units throughout: a timestamp, an index. */\n at: number\n value: number | null\n}\n\nexport interface Bounds {\n /** The value the left edge stands for. */\n from: number\n /** The right edge. */\n to: number\n /** The bottom of the plot. */\n min: number\n /** The top. */\n max: number\n}\n\n/** A point placed in the box, in percentages: x from the left, y from the top\n * (SVG's own direction, so a larger value has a smaller y). */\nexport interface Placed {\n x: number\n y: number\n at: number\n value: number\n}\n\n/**\n * The bounds a series is drawn against.\n *\n * The y range does NOT start at zero by default, and that is a deliberate\n * departure from the bar's rule. A bar's length *is* the quantity, so its\n * baseline has to be zero or the length lies. A line's job is the shape of a\n * change, and a balance moving between 4,900 and 5,100 flattens into a\n * horizontal rule on a zero-based axis - the very thing the reader opened the\n * chart to see. What keeps it honest is that the axis is labelled: the ticks\n * say where the bottom is, so nobody reads the floor as nothing.\n *\n * A caller who wants zero states it, and for a quantity that is a count rather\n * than a level - requests, errors - they should.\n */\nexport function boundsOf(points: Point[], stated: Partial<Bounds> = {}): Bounds | null {\n const drawn = points.filter((point): point is Point & { value: number } => point.value !== null)\n if (drawn.length === 0) return null\n\n const values = drawn.map((point) => point.value)\n const positions = points.map((point) => point.at)\n\n const from = stated.from ?? Math.min(...positions)\n const to = stated.to ?? Math.max(...positions)\n\n let min = stated.min ?? Math.min(...values)\n let max = stated.max ?? Math.max(...values)\n\n /* A flat series has no range to scale against, and dividing by it would put\n * every point at the same y - or at NaN. Given a band around the value, the\n * line sits in the middle of the plot and reads as what it is: unchanging. */\n if (!(max > min)) {\n const pad = Math.abs(max) > 0 ? Math.abs(max) * 0.1 : 1\n min = max - pad\n max = max + pad\n }\n\n return { from, to, min, max }\n}\n\n/**\n * The series as runs of consecutive readings.\n *\n * A hole breaks the line rather than being drawn through or closed up, and the\n * three options are worth naming because two of them lie:\n *\n * **Interpolating across** invents a reading nobody took - the one thing a\n * chart of measurements must never do.\n *\n * **Dropping the point** keeps the line whole and moves every later reading\n * to the left, so the axis stops matching the data: a chart that says the\n * value was 40 in March when it was 40 in April.\n *\n * **Breaking the line** leaves the gap visible, keeps every other point\n * where it belongs, and invents nothing. So that is what happens here.\n *\n * A run of one point is kept: it has no line, but it has a dot, and dropping\n * it would hide a reading that exists.\n */\nexport function runs(points: Point[], bounds: Bounds): Placed[][] {\n const span = bounds.to - bounds.from\n const height = bounds.max - bounds.min\n if (!(span > 0) || !(height > 0)) return []\n\n const out: Placed[][] = []\n let run: Placed[] = []\n\n for (const point of points) {\n if (point.value === null) {\n if (run.length > 0) out.push(run)\n run = []\n continue\n }\n\n const clamped = Math.min(Math.max(point.value, bounds.min), bounds.max)\n run.push({\n x: ((point.at - bounds.from) / span) * 100,\n // SVG's y grows downward, so the largest value sits at the top.\n y: ((bounds.max - clamped) / height) * 100,\n at: point.at,\n value: point.value,\n })\n }\n\n if (run.length > 0) out.push(run)\n return out\n}\n\n/** A run as an SVG path, in the same percentages.\n *\n * Straight segments rather than a curve: a spline through measured points\n * overshoots between them, inventing highs and lows that were never recorded -\n * the same lie as interpolating across a gap, drawn more prettily. */\nexport function pathOf(run: Placed[]): string {\n return run\n .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`)\n .join(' ')\n}\n\n/**\n * Ticks for the value axis, on round numbers.\n *\n * Not the range cut into equal parts: 4,900 to 5,100 in four gives 4,950 and\n * 5,050, which nobody reads as a landmark. A tick's job is to be recognised\n * instantly, so the step is the nearest 1, 2, 5 or 10 above what the range\n * needs, and the ticks are the multiples of it inside the range.\n *\n * Returns nothing when a step would not fit - a range too narrow for a round\n * number is better with no ticks than with invented ones.\n */\nexport function ticksFor(bounds: Bounds, wanted = 4): number[] {\n const height = bounds.max - bounds.min\n if (!(height > 0) || wanted < 1) return []\n\n const rough = height / wanted\n const magnitude = 10 ** Math.floor(Math.log10(rough))\n const step = [1, 2, 5, 10].map((n) => n * magnitude).find((candidate) => candidate >= rough)\n if (step === undefined) return []\n\n const ticks: number[] = []\n for (let tick = Math.ceil(bounds.min / step) * step; tick <= bounds.max; tick += step) {\n // Floating point leaves 0.30000000000000004 where 0.3 was meant; the step\n // is what decides how many decimals are real.\n const decimals = Math.max(0, -Math.floor(Math.log10(step)))\n ticks.push(Number(tick.toFixed(decimals)))\n }\n\n return ticks\n}\n\n/** Where a value sits in the plot, as a percentage from the top - for a tick's\n * rule, or a threshold drawn across the line. */\nexport function yOf(value: number, bounds: Bounds): number | null {\n const height = bounds.max - bounds.min\n if (!(height > 0)) return null\n if (value < bounds.min || value > bounds.max) return null\n return ((bounds.max - value) / height) * 100\n}\n"
931
+ }
932
+ ]
933
+ },
782
934
  {
783
935
  "name": "menu",
784
936
  "type": "registry:ui",
@@ -787,7 +939,7 @@
787
939
  "dependencies": [
788
940
  "@base-ui/react",
789
941
  "class-variance-authority",
790
- "dowel-ui@^0.22.0"
942
+ "dowel-ui@^0.24.0"
791
943
  ],
792
944
  "registryDependencies": [],
793
945
  "files": [
@@ -806,7 +958,7 @@
806
958
  "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.",
807
959
  "dependencies": [
808
960
  "@base-ui/react",
809
- "dowel-ui@^0.22.0"
961
+ "dowel-ui@^0.24.0"
810
962
  ],
811
963
  "registryDependencies": [
812
964
  "https://lacodda.github.io/dowel/r/input.json"
@@ -826,7 +978,7 @@
826
978
  "title": "Number-format",
827
979
  "description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
828
980
  "dependencies": [
829
- "dowel-ui@^0.22.0"
981
+ "dowel-ui@^0.24.0"
830
982
  ],
831
983
  "registryDependencies": [],
832
984
  "files": [
@@ -844,7 +996,7 @@
844
996
  "title": "Page-size",
845
997
  "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.",
846
998
  "dependencies": [
847
- "dowel-ui@^0.22.0"
999
+ "dowel-ui@^0.24.0"
848
1000
  ],
849
1001
  "registryDependencies": [
850
1002
  "https://lacodda.github.io/dowel/r/select.json"
@@ -864,7 +1016,7 @@
864
1016
  "title": "Pagination",
865
1017
  "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.",
866
1018
  "dependencies": [
867
- "dowel-ui@^0.22.0"
1019
+ "dowel-ui@^0.24.0"
868
1020
  ],
869
1021
  "registryDependencies": [
870
1022
  "https://lacodda.github.io/dowel/r/button.json"
@@ -885,7 +1037,7 @@
885
1037
  "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.",
886
1038
  "dependencies": [
887
1039
  "class-variance-authority",
888
- "dowel-ui@^0.22.0"
1040
+ "dowel-ui@^0.24.0"
889
1041
  ],
890
1042
  "registryDependencies": [],
891
1043
  "files": [
@@ -903,7 +1055,7 @@
903
1055
  "title": "Password-field",
904
1056
  "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.",
905
1057
  "dependencies": [
906
- "dowel-ui@^0.22.0"
1058
+ "dowel-ui@^0.24.0"
907
1059
  ],
908
1060
  "registryDependencies": [
909
1061
  "https://lacodda.github.io/dowel/r/input.json"
@@ -925,7 +1077,7 @@
925
1077
  "dependencies": [
926
1078
  "@base-ui/react",
927
1079
  "class-variance-authority",
928
- "dowel-ui@^0.22.0"
1080
+ "dowel-ui@^0.24.0"
929
1081
  ],
930
1082
  "registryDependencies": [],
931
1083
  "files": [
@@ -945,7 +1097,7 @@
945
1097
  "dependencies": [
946
1098
  "@base-ui/react",
947
1099
  "class-variance-authority",
948
- "dowel-ui@^0.22.0"
1100
+ "dowel-ui@^0.24.0"
949
1101
  ],
950
1102
  "registryDependencies": [],
951
1103
  "files": [
@@ -957,6 +1109,45 @@
957
1109
  }
958
1110
  ]
959
1111
  },
1112
+ {
1113
+ "name": "progress",
1114
+ "type": "registry:ui",
1115
+ "title": "Progress",
1116
+ "description": "The distinction the component is built on, and the one products collapse:\n * **determinate** - the fraction is known. The bar fills to it, and a reader can tell how long is left.",
1117
+ "dependencies": [
1118
+ "@base-ui/react",
1119
+ "class-variance-authority",
1120
+ "dowel-ui@^0.24.0"
1121
+ ],
1122
+ "registryDependencies": [],
1123
+ "files": [
1124
+ {
1125
+ "path": "ui/progress.tsx",
1126
+ "target": "@ui/progress.tsx",
1127
+ "type": "registry:ui",
1128
+ "content": "import { Progress as Base } from '@base-ui/react/progress'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * How far along something is.\n *\n * The distinction the component is built on, and the one products collapse:\n *\n * **determinate** - the fraction is known. The bar fills to it, and a reader\n * can tell how long is left.\n *\n * **indeterminate** - something is happening and nobody knows how much is\n * left. The bar says exactly that, by moving without filling.\n *\n * Collapsing them means picking a number that is not true - a bar that creeps\n * to 90% and waits there is the commonest version, and it is a lie the reader\n * learns to distrust, after which no progress bar in the product means\n * anything. `value={undefined}` is the honest answer, and it is the default.\n *\n * Base UI carries the role, the announcement and the value clamping. What is\n * here is the clothes, and the rule about which of the two states is drawn.\n *\n * Not a Spinner. A spinner says \"working\" in a corner; this says \"working, and\n * here is the shape of it\" across a width. Where the shape of what is coming\n * is known, a Skeleton says more than either.\n */\n\nexport const progressVariants = cva('w-full overflow-hidden rounded-full bg-soft', {\n variants: {\n size: {\n sm: 'h-1',\n md: 'h-2',\n },\n tone: {\n accent: '',\n good: '',\n warn: '',\n bad: '',\n },\n },\n defaultVariants: { size: 'md', tone: 'accent' },\n})\n\nconst fillVariants = cva('h-full rounded-full transition-[width] duration-base', {\n variants: {\n tone: {\n accent: 'bg-accent',\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\n/* The stripes take their colour from `currentColor`, so the tone arrives as a\n * text colour rather than a background - the same four names, said the other\n * way round. */\nconst stripeVariants = cva('h-full w-full rounded-full opacity-40', {\n variants: {\n tone: {\n accent: 'text-accent',\n good: 'text-good',\n warn: 'text-warn',\n bad: 'text-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport interface ProgressProps\n extends Omit<Base.Root.Props, 'className' | 'value'>,\n VariantProps<typeof progressVariants> {\n /** The fraction done, 0 to `max`. Leave it out when it is not known - that\n * is not a missing value but a different, honest state. */\n value?: number | null\n max?: number\n /** What is progressing, for a screen reader. Required: a bar with no name is\n * announced as a percentage of nothing, and the word belongs to the product. */\n label: string\n className?: string\n /** A visible label beside the bar, when there is room. */\n children?: React.ReactNode\n}\n\nexport function Progress({\n value,\n max = 100,\n size,\n tone,\n label,\n className,\n children,\n ...props\n}: ProgressProps) {\n const indeterminate = value === undefined || value === null\n\n return (\n <Base.Root\n value={indeterminate ? null : value}\n max={max}\n aria-label={label}\n className={cn('flex w-full flex-col gap-1.5', className)}\n {...props}\n >\n {children !== undefined && (\n <div className=\"flex items-baseline justify-between text-xs text-dim\">\n {children}\n {/* The number only where there is one. Showing \"0%\" for an unknown\n * amount is the same lie as a bar that creeps to 90%. */}\n {!indeterminate && (\n <span className=\"tabular-nums\">{Math.round((value / max) * 100)}%</span>\n )}\n </div>\n )}\n\n <Base.Track className={cn(progressVariants({ size, tone }))}>\n {indeterminate ? (\n /* Stripes across the whole track, not a filled bar.\n *\n * A full-width fill was the first version and it was wrong in the\n * one way that matters: measured on the stand, it drew 384px of a\n * 384px track - a reader glancing at it sees \"done\", which is the\n * opposite of what the state means. Pulsing did not rescue it, and\n * under `prefers-reduced-motion` the pulse stops and a solid,\n * complete-looking bar is all that remains.\n *\n * Stripes cannot be read as a fraction at all: there is no edge to\n * take for a boundary. They are drawn with a gradient rather than a\n * `@keyframes` of their own, because a name in the theme is a\n * contract the line carries forever - and this needs no animation to\n * say what it says. */\n <div\n className={cn(stripeVariants({ tone }))}\n style={{\n backgroundImage:\n 'repeating-linear-gradient(45deg, currentColor 0 6px, transparent 6px 12px)',\n }}\n />\n ) : (\n <Base.Indicator className={cn(fillVariants({ tone }))} />\n )}\n </Base.Track>\n </Base.Root>\n )\n}\n"
1129
+ }
1130
+ ]
1131
+ },
1132
+ {
1133
+ "name": "query-state",
1134
+ "type": "registry:ui",
1135
+ "title": "Query-state",
1136
+ "description": "Every list in every product writes the same ladder - loading, then failed, then nothing found, then the content - and writes it slightly differently each time. What differs is never deliberate: one screen forgets the empty case, another shows a spinner where the shape was known, a third prints the raw error object. This is that ladder, once.",
1137
+ "dependencies": [],
1138
+ "registryDependencies": [
1139
+ "https://lacodda.github.io/dowel/r/empty-state.json",
1140
+ "https://lacodda.github.io/dowel/r/skeleton.json"
1141
+ ],
1142
+ "files": [
1143
+ {
1144
+ "path": "ui/query-state.tsx",
1145
+ "target": "@ui/query-state.tsx",
1146
+ "type": "registry:ui",
1147
+ "content": "import type { ReactNode } from 'react'\nimport { EmptyState } from './empty-state'\nimport { SkeletonList } from './skeleton'\n\n/*\n * The three screens between asking for data and showing it.\n *\n * Every list in every product writes the same ladder - loading, then failed,\n * then nothing found, then the content - and writes it slightly differently\n * each time. What differs is never deliberate: one screen forgets the empty\n * case, another shows a spinner where the shape was known, a third prints the\n * raw error object. This is that ladder, once.\n *\n * **It takes values, not a query.** `useQuery` from TanStack Query hands back\n * `isPending` and `error`, and those are ordinary values - so this component\n * asks for them rather than for the query result, and works the same with SWR,\n * with a reducer, or with two `useState` calls. Taking the result object would\n * put a library in the registry and therefore in every product that installs\n * this primitive, to save one line at the call site:\n *\n * const works = useQuery({ queryKey, queryFn })\n * <QueryState pending={works.isPending} error={works.error} empty={!works.data?.length} …>\n *\n * **The order of the cases is the component**, and it is the part that goes\n * wrong by hand: pending first, because a refetch that already has data should\n * not blank the screen; then error, because an error with stale data is still\n * an error; then empty, which is only knowable once something arrived.\n *\n * What it deliberately does not do is fetch, retry or cache. Those belong to\n * whatever owns the data - and a component that guessed at them would be\n * wrong for the product that owns them differently.\n */\n\nexport interface QueryStateProps {\n /** Nothing has arrived yet. */\n pending?: boolean\n /** It failed. Anything with a `message`, which is what every error library\n * agrees on - or a string, for a product that carries its own. */\n error?: { message: string } | string | null\n /** Something arrived, and it was nothing. Computed by the caller, because\n * only the caller knows whether empty means `[]`, `null` or a count of\n * zero. */\n empty?: boolean\n /** What stands in while pending. A shape, ideally - the default is a list,\n * because most screens are waiting for one, but a screen waiting for a card\n * should say so. */\n skeleton?: ReactNode\n /** What is shown when nothing came back. */\n emptyState?: ReactNode\n /** What is shown when it failed. Given the message, so a product can put it\n * where it likes - or ignore it. */\n errorState?: (message: string) => ReactNode\n /** The words on the default error screen. Required only in the sense that\n * the default screen needs them; pass `errorState` instead and they are\n * never read. */\n errorLabels?: { title: string; body?: string }\n children: ReactNode\n}\n\nexport function QueryState({\n pending = false,\n error = null,\n empty = false,\n skeleton,\n emptyState,\n errorState,\n errorLabels,\n children,\n}: QueryStateProps) {\n /* `aria-busy` on the wrapper, in every state.\n *\n * This is what tells a screen reader that the region is working, and it is\n * why the Skeletons themselves are `aria-hidden`: the fact belongs to the\n * region, said once, rather than to a dozen empty boxes announced as\n * content. */\n const wrap = (content: ReactNode) => <div aria-busy={pending || undefined}>{content}</div>\n\n // Pending first: a refetch that still holds data should not blank the screen\n // it is refreshing, and the caller decides that by passing `pending` only\n // when there is nothing to show.\n if (pending) return wrap(skeleton ?? <SkeletonList />)\n\n if (error) {\n const message = typeof error === 'string' ? error : error.message\n if (errorState) return wrap(errorState(message))\n return wrap(\n <EmptyState\n variant=\"error\"\n title={errorLabels?.title ?? message}\n // The message is shown as the body when there is a title above it, and\n // as the title when there is not - so it is never lost, and never\n // printed twice.\n body={errorLabels?.title ? (errorLabels.body ?? message) : errorLabels?.body}\n />,\n )\n }\n\n // Empty last of the three: it is the only one that cannot be known until\n // something has arrived.\n if (empty && emptyState) return wrap(emptyState)\n\n return wrap(children)\n}\n"
1148
+ }
1149
+ ]
1150
+ },
960
1151
  {
961
1152
  "name": "radio-group",
962
1153
  "type": "registry:ui",
@@ -965,7 +1156,7 @@
965
1156
  "dependencies": [
966
1157
  "@base-ui/react",
967
1158
  "class-variance-authority",
968
- "dowel-ui@^0.22.0"
1159
+ "dowel-ui@^0.24.0"
969
1160
  ],
970
1161
  "registryDependencies": [],
971
1162
  "files": [
@@ -983,7 +1174,7 @@
983
1174
  "title": "Rating-scale",
984
1175
  "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\".",
985
1176
  "dependencies": [
986
- "dowel-ui@^0.22.0"
1177
+ "dowel-ui@^0.24.0"
987
1178
  ],
988
1179
  "registryDependencies": [],
989
1180
  "files": [
@@ -1001,7 +1192,7 @@
1001
1192
  "title": "Relative-time",
1002
1193
  "description": "The relative-time primitive.",
1003
1194
  "dependencies": [
1004
- "dowel-ui@^0.22.0"
1195
+ "dowel-ui@^0.24.0"
1005
1196
  ],
1006
1197
  "registryDependencies": [],
1007
1198
  "files": [
@@ -1019,7 +1210,7 @@
1019
1210
  "title": "Save-state",
1020
1211
  "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.",
1021
1212
  "dependencies": [
1022
- "dowel-ui@^0.22.0"
1213
+ "dowel-ui@^0.24.0"
1023
1214
  ],
1024
1215
  "registryDependencies": [
1025
1216
  "https://lacodda.github.io/dowel/r/spinner.json"
@@ -1039,7 +1230,7 @@
1039
1230
  "title": "Search-field",
1040
1231
  "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.",
1041
1232
  "dependencies": [
1042
- "dowel-ui@^0.22.0"
1233
+ "dowel-ui@^0.24.0"
1043
1234
  ],
1044
1235
  "registryDependencies": [
1045
1236
  "https://lacodda.github.io/dowel/r/input.json",
@@ -1063,7 +1254,7 @@
1063
1254
  "dependencies": [
1064
1255
  "@base-ui/react",
1065
1256
  "class-variance-authority",
1066
- "dowel-ui@^0.22.0"
1257
+ "dowel-ui@^0.24.0"
1067
1258
  ],
1068
1259
  "registryDependencies": [
1069
1260
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1093,6 +1284,24 @@
1093
1284
  }
1094
1285
  ]
1095
1286
  },
1287
+ {
1288
+ "name": "skeleton",
1289
+ "type": "registry:ui",
1290
+ "title": "Skeleton",
1291
+ "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
+ "dependencies": [
1293
+ "dowel-ui@^0.24.0"
1294
+ ],
1295
+ "registryDependencies": [],
1296
+ "files": [
1297
+ {
1298
+ "path": "ui/skeleton.tsx",
1299
+ "target": "@ui/skeleton.tsx",
1300
+ "type": "registry:ui",
1301
+ "content": "import type { HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * A placeholder shaped like the thing that is still loading.\n *\n * The rule the component is built on, and the reason it takes a shape rather\n * than filling the space:\n *\n * **A skeleton of the wrong shape is worse than no skeleton.**\n *\n * It promises something the content does not keep, and the promise is paid for\n * in a jump: the page settles, the scrollbar appears, and whatever the reader\n * was about to click has moved. Measured rather than assumed - the line's own\n * calendar showed a list of four short lines where a six-row month grid was\n * about to land, and the skeleton was itself the jump it existed to prevent.\n *\n * So the useful thing here is not `<Skeleton />` - that is four lines anyone\n * can write - but the shapes: a list, a card, a grid. They are what a product\n * reaches for at the call site, and what keeps the placeholder honest.\n *\n * A spinner is the right answer when the shape is *not* known. A skeleton\n * claims to know; if it does not, say less rather than more.\n *\n * Everything here is `aria-hidden`. A screen reader is told the region is busy\n * by whatever owns the loading state - `QueryState` does it with `aria-busy` -\n * and announcing a dozen empty boxes as content would be noise on top of a\n * fact the reader already has.\n */\n\nexport type SkeletonProps = HTMLAttributes<HTMLDivElement>\n\n/** One block. Size it with `className` - a skeleton is a shape, and the shape\n * belongs to whatever it stands in for. */\nexport function Skeleton({ className, ...props }: SkeletonProps) {\n return (\n <div\n aria-hidden\n className={cn('animate-pulse rounded-md bg-soft', className)}\n {...props}\n />\n )\n}\n\nexport interface SkeletonTextProps extends HTMLAttributes<HTMLDivElement> {\n /** How many lines of text stand here. */\n lines?: number\n}\n\n/** A paragraph's worth of lines.\n *\n * The widths vary, and that is the whole point: a stack of equal bars reads as\n * a loading indicator, while ragged ones read as text. The last line is short,\n * because the last line of a paragraph is. */\nexport function SkeletonText({ lines = 3, className, ...props }: SkeletonTextProps) {\n const widths = ['w-full', 'w-11/12', 'w-4/5', 'w-full', 'w-3/4']\n return (\n <div aria-hidden className={cn('flex flex-col gap-2', className)} {...props}>\n {Array.from({ length: Math.max(1, lines) }, (_, line) => (\n <Skeleton\n key={line}\n className={cn(\n 'h-3',\n line === lines - 1 ? 'w-1/2' : widths[line % widths.length],\n )}\n />\n ))}\n </div>\n )\n}\n\nexport interface SkeletonListProps extends HTMLAttributes<HTMLDivElement> {\n rows?: number\n /** Draw a second, shorter line under each row - for a list whose rows carry\n * a title and something beneath it. */\n secondary?: boolean\n}\n\n/** Rows of a list, which is the shape most screens are waiting for. */\nexport function SkeletonList({\n rows = 5,\n secondary = true,\n className,\n ...props\n}: SkeletonListProps) {\n // Three widths in rotation rather than random: a placeholder that differs\n // between renders is a placeholder that flickers when anything re-renders.\n const widths = ['w-2/3', 'w-4/5', 'w-1/2']\n return (\n <div aria-hidden className={cn('flex flex-col gap-1', className)} {...props}>\n {Array.from({ length: Math.max(1, rows) }, (_, row) => (\n <div key={row} className=\"flex flex-col gap-1.5 px-3 py-2\">\n <Skeleton className={cn('h-3.5', widths[row % widths.length])} />\n {secondary && <Skeleton className=\"h-2.5 w-1/3\" />}\n </div>\n ))}\n </div>\n )\n}\n\nexport interface SkeletonGridProps extends HTMLAttributes<HTMLDivElement> {\n /** How many cells. */\n cells?: number\n /** How many per row. */\n columns?: number\n /** The aspect of one cell, as a Tailwind class - a gallery of covers is not\n * shaped like a grid of tiles. */\n cellClassName?: string\n}\n\n/** A grid of cells: a gallery, a board, a month.\n *\n * Given a cell count and a column count rather than a shape of its own,\n * because the grids a product waits for differ in both and agree in neither. */\nexport function SkeletonGrid({\n cells = 12,\n columns = 4,\n cellClassName = 'aspect-square',\n className,\n ...props\n}: SkeletonGridProps) {\n return (\n <div\n aria-hidden\n // The column count is a style rather than a class, because a class would\n // have to be one of a fixed set - and Tailwind cannot generate\n // `grid-cols-${n}` from a value it never sees.\n style={{ gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))` }}\n className={cn('grid gap-2', className)}\n {...props}\n >\n {Array.from({ length: Math.max(1, cells) }, (_, cell) => (\n <Skeleton key={cell} className={cellClassName} />\n ))}\n </div>\n )\n}\n"
1302
+ }
1303
+ ]
1304
+ },
1096
1305
  {
1097
1306
  "name": "slider",
1098
1307
  "type": "registry:ui",
@@ -1100,7 +1309,7 @@
1100
1309
  "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\".",
1101
1310
  "dependencies": [
1102
1311
  "@base-ui/react",
1103
- "dowel-ui@^0.22.0"
1312
+ "dowel-ui@^0.24.0"
1104
1313
  ],
1105
1314
  "registryDependencies": [],
1106
1315
  "files": [
@@ -1112,6 +1321,25 @@
1112
1321
  }
1113
1322
  ]
1114
1323
  },
1324
+ {
1325
+ "name": "sparkline",
1326
+ "type": "registry:ui",
1327
+ "title": "Sparkline",
1328
+ "description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
1329
+ "dependencies": [
1330
+ "class-variance-authority",
1331
+ "dowel-ui@^0.24.0"
1332
+ ],
1333
+ "registryDependencies": [],
1334
+ "files": [
1335
+ {
1336
+ "path": "ui/sparkline.tsx",
1337
+ "target": "@ui/sparkline.tsx",
1338
+ "type": "registry:ui",
1339
+ "content": "import type { SVGAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * A line small enough to sit beside the number it belongs to.\n *\n * The shape of a history, not a chart of it: no axes, no gridlines, no ticks.\n * \"61 → 74 → 82\" reads perfectly at three points and stops working at ten; a\n * line holds both, and the exact figures stay in the list underneath.\n *\n * Three things here are not free choices.\n *\n * **The box is drawn at the size it is shown at.** A `viewBox` wider than the\n * element scales x and y by different factors: the line bends away from the\n * data and the end dot stretches into a wedge. So the size is a variant rather\n * than a `className`, and each variant states the same numbers twice on\n * purpose - once for the geometry, once for the element - from one place. The\n * donor took a `size` object *and* a class, and every call site repeated\n * itself: `size={{ width: 52, height: 16 }} className=\"h-4 w-[52px]\"`.\n *\n * **The scale comes from outside.** `max` is what the axis allows, not what\n * this line happens to reach, so two works can be compared by eye. Normalised\n * to itself, a line that moved 61 → 63 would climb the whole box and read as a\n * transformation.\n *\n * **The line does not judge.** The donor coloured it green when it ended\n * higher and red when it ended lower, which is a claim the component cannot\n * support: for time-to-answer or error rate, down is the good direction. So\n * the line is drawn in the de-emphasis tone and the newest point in the accent\n * - the eye goes to \"where it is now\", and what that means is said in words\n * next to it, usually by a StatTile's delta. `tone` is there for a caller who\n * genuinely knows the direction's meaning.\n *\n * Not interactive, and that is the form rather than an omission: a sparkline\n * has no room for a hover target that is not the whole of it. A reader who\n * needs the exact figures gets them from the table beside it.\n */\n\nconst GEOMETRY = {\n sm: { width: 52, height: 16, dot: 1.75, stroke: 1.25 },\n md: { width: 120, height: 28, dot: 2.5, stroke: 1.5 },\n} as const\n\nexport type SparklineSize = keyof typeof GEOMETRY\n\nexport const sparklineVariants = cva('shrink-0 overflow-visible', {\n variants: {\n size: {\n /* Beside a figure in a row - an axis of a rubric, a cell in a table. */\n sm: 'h-4 w-[52px]',\n /* Beside a total, where the shape is meant to be read rather than\n * glanced at. */\n md: 'h-7 w-[120px]',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const sparklineLineVariants = cva('fill-none', {\n variants: {\n tone: {\n /* The default, and the one a caller should almost always leave alone:\n * the line is context for the number beside it. */\n muted: 'stroke-dim',\n /* For a line that IS the subject - one chart on a page, nothing else to\n * defer to. */\n accent: 'stroke-accent',\n /* Only where the caller knows what the direction means. A component\n * cannot: for time-to-answer, down is the good news. */\n good: 'stroke-good',\n bad: 'stroke-bad',\n },\n },\n defaultVariants: { tone: 'muted' },\n})\n\nexport const sparklineDotVariants = cva('', {\n variants: {\n tone: {\n /* The newest point carries the accent even under the muted line: it is\n * the one the eye is looking for, and one dot of colour is enough to\n * find it without the line making a claim. */\n muted: 'fill-accent',\n accent: 'fill-accent',\n good: 'fill-good',\n bad: 'fill-bad',\n },\n },\n defaultVariants: { tone: 'muted' },\n})\n\n/** Breathing room, so the stroke and the end dot are not clipped by the box. */\nconst PAD = 3\n\nexport interface SparklineProps\n extends Omit<SVGAttributes<SVGSVGElement>, 'values'>,\n VariantProps<typeof sparklineLineVariants> {\n /** Oldest first. Fewer than two points is not a line, and draws nothing. */\n values: number[]\n /** The top of the scale, so two lines can be compared by eye. Defaults to\n * the highest value present, which makes the line self-scaled - fine for one\n * line alone, wrong for a column of them. */\n max?: number\n /** What the line says, for a reader who cannot see it. Required: an unlabelled\n * `img` is an unlabelled image, and this one carries information. */\n label: string\n size?: SparklineSize\n}\n\nexport function Sparkline({ values, max, label, size, tone, className, ...props }: SparklineProps) {\n // One point is a dot with no direction and no point; zero is nothing at all.\n if (values.length < 2) return null\n\n /* The default lives in `sparklineVariants` like every other variant in the\n * library; this resolves the same word for the geometry, so the numbers and\n * the classes cannot disagree about which size is being drawn. */\n const { width, height, dot, stroke } = GEOMETRY[size ?? 'md']\n\n /* The ceiling never sits below the data: a value above `max` would otherwise\n * be drawn outside the box. Clamped rather than rejected, because a score\n * that overshoots its stated scale is the caller's problem to notice, not a\n * reason to draw nothing. */\n const top = Math.max(...values, max ?? 0)\n /* A flat line at zero, or a flat line anywhere with no stated scale, would\n * divide by zero. It is drawn along the bottom, which is where it belongs. */\n const span = width - PAD * 2\n const rise = height - PAD * 2\n\n const points = values.map((value, index) => {\n const x = PAD + (span * index) / (values.length - 1)\n // SVG's y grows downward, so a larger value has to sit higher up.\n const y = top === 0 ? PAD + rise : PAD + rise - (rise * value) / top\n return [x, y] as const\n })\n\n const path = points\n .map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`)\n .join(' ')\n const [lastX, lastY] = points.at(-1)!\n\n return (\n <svg\n viewBox={`0 0 ${width} ${height}`}\n className={cn(sparklineVariants({ size }), className)}\n role=\"img\"\n aria-label={label}\n {...props}\n >\n <path\n d={path}\n strokeWidth={stroke}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n className={cn(sparklineLineVariants({ tone }))}\n />\n <circle cx={lastX} cy={lastY} r={dot} className={cn(sparklineDotVariants({ tone }))} />\n </svg>\n )\n}\n"
1340
+ }
1341
+ ]
1342
+ },
1115
1343
  {
1116
1344
  "name": "spinner",
1117
1345
  "type": "registry:ui",
@@ -1119,7 +1347,7 @@
1119
1347
  "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.",
1120
1348
  "dependencies": [
1121
1349
  "class-variance-authority",
1122
- "dowel-ui@^0.22.0"
1350
+ "dowel-ui@^0.24.0"
1123
1351
  ],
1124
1352
  "registryDependencies": [],
1125
1353
  "files": [
@@ -1131,6 +1359,25 @@
1131
1359
  }
1132
1360
  ]
1133
1361
  },
1362
+ {
1363
+ "name": "stat-tile",
1364
+ "type": "registry:ui",
1365
+ "title": "Stat-tile",
1366
+ "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
+ "dependencies": [
1368
+ "class-variance-authority",
1369
+ "dowel-ui@^0.24.0"
1370
+ ],
1371
+ "registryDependencies": [],
1372
+ "files": [
1373
+ {
1374
+ "path": "ui/stat-tile.tsx",
1375
+ "target": "@ui/stat-tile.tsx",
1376
+ "type": "registry:ui",
1377
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * One figure, and what it is a figure of.\n *\n * The smallest thing on a dashboard and the one every product writes itself:\n * a label above, a number below, sometimes a word about which way it moved.\n * It is here because the first consumer had written it twice - once on the\n * personal page, once on the team one - and the copies had already drifted:\n * one had grown a warning tone the other lacked, and the tone classes in it\n * were concatenated without a space, so a figure that was both accented and\n * warning would have emitted `text-accent-2text-warn` and been styled by\n * neither. Nothing had gone wrong on screen yet; the two flags were simply\n * never passed together.\n *\n * A `<dl>` rather than two divs, for the reason KeyValue is one: the pairing\n * is what a screen reader announces. Loose divs read as two unrelated pieces\n * of text and nothing says the number belongs to the label.\n *\n * Numbers are set in the mono face with tabular figures, so a column of tiles\n * lines up and a value that ticks does not shuffle its neighbours sideways.\n * That matters more than it sounds: a live figure redrawn every few seconds in\n * proportional digits makes the whole row twitch.\n *\n * The delta is a second, quieter line rather than a colour on the value. A\n * number that turns red is a number whose colour has to be explained, and the\n * explanation is never on the screen; a delta says \"+12% vs last week\" and\n * needs nothing. Its tone is stated by the caller rather than inferred from\n * the sign, because down is good for a figure like \"time to first response\",\n * and a component cannot know which figure it is holding.\n */\n\nexport const statTileVariants = cva('min-w-0', {\n variants: {\n size: {\n /* The dashboard default: a row of these under a heading. */\n md: '',\n /* For a tile that leads a page rather than sitting in a row of six. */\n lg: '',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const statTileValueVariants = cva('mt-1 font-mono tabular-nums', {\n variants: {\n size: {\n md: 'text-lg',\n lg: 'text-2xl',\n },\n tone: {\n /* The reading tone: what this figure is, not how it is doing. `accent`\n * marks the one figure a panel is really about; `warn` and `bad` are for\n * a figure that is itself a problem - people with no agent reporting,\n * a queue that is backing up. */\n default: 'text-text',\n accent: 'text-accent-2',\n warn: 'text-warn',\n bad: 'text-bad',\n },\n },\n defaultVariants: { size: 'md', tone: 'default' },\n})\n\nexport const statTileDeltaVariants = cva('mt-1 text-xs', {\n variants: {\n tone: {\n /* Neutral by default, because most movement is just movement. */\n default: 'text-dim',\n good: 'text-good',\n bad: 'text-bad',\n },\n },\n defaultVariants: { tone: 'default' },\n})\n\nexport interface StatTileProps\n extends Omit<HTMLAttributes<HTMLDListElement>, 'title'>,\n VariantProps<typeof statTileVariants> {\n /** What the figure is. */\n label: ReactNode\n /** The figure. Already formatted - a duration, a count, a percentage: this\n * component decides how a number looks, never what it says. */\n value: ReactNode\n /** How the value itself reads. */\n tone?: NonNullable<VariantProps<typeof statTileValueVariants>['tone']>\n /** Which way it moved, in words the caller chooses: `+12% vs last week`,\n * `3 fewer than yesterday`. Omitted when there is nothing to compare to -\n * an empty line here reads as \"unchanged\", which is a claim. */\n delta?: ReactNode\n /** Whether that movement is good news. Stated rather than read off the sign,\n * because for a figure like time-to-answer a fall is the good direction. */\n deltaTone?: NonNullable<VariantProps<typeof statTileDeltaVariants>['tone']>\n}\n\nexport function StatTile({\n label,\n value,\n tone,\n delta,\n deltaTone,\n size,\n className,\n ...props\n}: StatTileProps) {\n return (\n <dl className={cn(statTileVariants({ size }), className)} {...props}>\n <dt className=\"text-xs font-medium text-dim\">{label}</dt>\n <dd className={cn(statTileValueVariants({ size, tone }))}>{value}</dd>\n {/* A second `dd` for the same term: the spec allows several, and this is\n * what they are for - one fact with two parts. A `<div>` here would end\n * the description list's pairing, and the delta would be read as loose\n * text next to the number rather than as part of it. */}\n {delta !== undefined && delta !== null && (\n <dd className={cn(statTileDeltaVariants({ tone: deltaTone }))}>{delta}</dd>\n )}\n </dl>\n )\n}\n\n/*\n * A row of tiles.\n *\n * Both donors wrote the identical container - `flex flex-wrap items-baseline\n * gap-x-8 gap-y-3` inside a Panel - and both had to get `items-baseline`\n * right, which is the part that is easy to miss: without it, a tile carrying a\n * delta is taller than its neighbours and the whole row's numbers stop sharing\n * a line.\n *\n * Wrapping rather than a grid, because the number of figures is decided at\n * runtime (one donor hides two of its five until there is something to say),\n * and a grid with a fixed column count leaves a hole where a hidden tile was.\n */\nexport type StatRowProps = HTMLAttributes<HTMLDivElement>\n\nexport function StatRow({ className, ...props }: StatRowProps) {\n return <div className={cn('flex flex-wrap items-baseline gap-x-8 gap-y-3', className)} {...props} />\n}\n"
1378
+ }
1379
+ ]
1380
+ },
1134
1381
  {
1135
1382
  "name": "switch",
1136
1383
  "type": "registry:ui",
@@ -1138,7 +1385,7 @@
1138
1385
  "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.",
1139
1386
  "dependencies": [
1140
1387
  "@base-ui/react",
1141
- "dowel-ui@^0.22.0"
1388
+ "dowel-ui@^0.24.0"
1142
1389
  ],
1143
1390
  "registryDependencies": [],
1144
1391
  "files": [
@@ -1173,7 +1420,7 @@
1173
1420
  "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.",
1174
1421
  "dependencies": [
1175
1422
  "class-variance-authority",
1176
- "dowel-ui@^0.22.0"
1423
+ "dowel-ui@^0.24.0"
1177
1424
  ],
1178
1425
  "registryDependencies": [
1179
1426
  "https://lacodda.github.io/dowel/r/table-sort.json"
@@ -1194,7 +1441,7 @@
1194
1441
  "description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
1195
1442
  "dependencies": [
1196
1443
  "class-variance-authority",
1197
- "dowel-ui@^0.22.0"
1444
+ "dowel-ui@^0.24.0"
1198
1445
  ],
1199
1446
  "registryDependencies": [
1200
1447
  "https://lacodda.github.io/dowel/r/chip.json",
@@ -1215,7 +1462,7 @@
1215
1462
  "title": "Textarea",
1216
1463
  "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.",
1217
1464
  "dependencies": [
1218
- "dowel-ui@^0.22.0"
1465
+ "dowel-ui@^0.24.0"
1219
1466
  ],
1220
1467
  "registryDependencies": [
1221
1468
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1235,7 +1482,7 @@
1235
1482
  "title": "Time-field",
1236
1483
  "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`.",
1237
1484
  "dependencies": [
1238
- "dowel-ui@^0.22.0"
1485
+ "dowel-ui@^0.24.0"
1239
1486
  ],
1240
1487
  "registryDependencies": [
1241
1488
  "https://lacodda.github.io/dowel/r/input.json"
@@ -1257,7 +1504,7 @@
1257
1504
  "dependencies": [
1258
1505
  "@base-ui/react",
1259
1506
  "class-variance-authority",
1260
- "dowel-ui@^0.22.0"
1507
+ "dowel-ui@^0.24.0"
1261
1508
  ],
1262
1509
  "registryDependencies": [],
1263
1510
  "files": [
@@ -1277,7 +1524,7 @@
1277
1524
  "dependencies": [
1278
1525
  "@base-ui/react",
1279
1526
  "class-variance-authority",
1280
- "dowel-ui@^0.22.0"
1527
+ "dowel-ui@^0.24.0"
1281
1528
  ],
1282
1529
  "registryDependencies": [],
1283
1530
  "files": [
@@ -1289,6 +1536,43 @@
1289
1536
  }
1290
1537
  ]
1291
1538
  },
1539
+ {
1540
+ "name": "track-segments",
1541
+ "type": "registry:ui",
1542
+ "title": "Track-segments",
1543
+ "description": "Split out for the reason `table-sort` and `tree-rows` are: a product that needs the numbers - to label a segment, to test its own domain code, to draw the same shape somewhere that is not the DOM - should not import a component to get them.",
1544
+ "dependencies": [],
1545
+ "registryDependencies": [],
1546
+ "files": [
1547
+ {
1548
+ "path": "ui/track-segments.tsx",
1549
+ "target": "@ui/track-segments.tsx",
1550
+ "type": "registry:ui",
1551
+ "content": "/*\n * The arithmetic behind Track, with no React in it.\n *\n * Split out for the reason `table-sort` and `tree-rows` are: a product that\n * needs the numbers - to label a segment, to test its own domain code, to draw\n * the same shape somewhere that is not the DOM - should not import a component\n * to get them.\n *\n * What lives here is only the geometry. Turning a day of work into segments,\n * or a set of tiers into them, is the product's own arithmetic and stays with\n * the product: this knows about spans and percentages and nothing else.\n */\n\n/** A segment as the caller states it, in whatever units the caller counts in. */\nexport interface SegmentInput {\n /** Where it starts. Same units as `end` and as the track's span. */\n start: number\n /** Where it ends. A segment ending before it starts is empty, not backwards. */\n end: number\n}\n\n/** A segment as it is drawn: percentages of the track. */\nexport interface Placed {\n /** Distance from the left edge, 0-100. */\n left: number\n /** Width, 0-100. */\n width: number\n /** Whether the width is the real one, or the floor standing in for it.\n *\n * Worth knowing rather than hiding: a caller labelling segments may want to\n * say \"under a minute\" instead of a duration the bar is no longer drawing to\n * scale. */\n widened: boolean\n}\n\n/** The smallest a segment may be drawn, in percent.\n *\n * A ten-second pause in an eight-hour day is 0.03% of the track, which rounds\n * to no pixels at all: the segment is real, was measured, and would simply not\n * be there. The floor is the width at which a sliver is still visible on a\n * track a few hundred pixels wide. */\nexport const MIN_SEGMENT_WIDTH = 0.6\n\nexport interface PlaceOptions {\n /** The value the left edge stands for. Defaults to the first segment's start. */\n from?: number\n /** The value the right edge stands for. Defaults to the last segment's end. */\n to?: number\n /** Override the floor - `0` to draw every segment exactly to scale. */\n minWidth?: number\n}\n\n/**\n * Lay segments out along a track, as percentages.\n *\n * The scale is stated by `from` and `to` rather than inferred, because the two\n * readings differ and both are wanted: a day of work is read against itself\n * (an eight-hour day drawn across a third of the width wastes the space where\n * the breaks are), while a set of tiers is read against the whole 0-100 scale,\n * where the distance to the next tier is the distance you have to close.\n *\n * Widening a sliver to the floor is what makes this worth having once. It also\n * introduces the only subtlety here: a widened segment can run into the one\n * after it, and two segments drawn overlapping is a worse lie than a segment\n * drawn slightly too wide. So the pass is done in order, and each segment is\n * held back to where the next one starts - a floor is a request, not a\n * guarantee, and the last one may be trimmed by the end of the track.\n */\nexport function place(segments: SegmentInput[], options: PlaceOptions = {}): Placed[] {\n if (segments.length === 0) return []\n\n const from = options.from ?? Math.min(...segments.map((s) => s.start))\n const to = options.to ?? Math.max(...segments.map((s) => s.end))\n const span = to - from\n\n // A track with no span has nothing to scale against: every segment would be\n // at the same place with the same width, which is not a drawing of anything.\n if (!(span > 0)) return []\n\n const floor = options.minWidth ?? MIN_SEGMENT_WIDTH\n\n const raw = segments.map((segment) => {\n const start = Math.min(Math.max(segment.start, from), to)\n const end = Math.min(Math.max(segment.end, start), to)\n return { left: ((start - from) / span) * 100, width: ((end - start) / span) * 100 }\n })\n\n if (floor <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n /*\n * Widening is a layout pass, not a per-segment decision, and the first\n * version of this got it wrong in a way worth recording: it capped each\n * sliver at \"where the next segment starts\", which protects against overlap\n * and also means a segment that touches its neighbour can never grow at all.\n * Both shapes this component exists for are contiguous - work then break\n * then work, one tier after another - so the floor did nothing for either of\n * them, and the tests missed it because their crowded case had a gap.\n *\n * So a widened segment pushes what follows instead. That borrows room the\n * track does not have, and the debt is paid back at the end by the segments\n * wide enough to afford it - never by another sliver, which would undo the\n * widening we just did.\n */\n const widened: Placed[] = []\n let shift = 0\n\n for (const segment of raw) {\n const left = segment.left + shift\n if (segment.width >= floor) {\n widened.push({ left, width: segment.width, widened: false })\n continue\n }\n shift += floor - segment.width\n widened.push({ left, width: floor, widened: true })\n }\n\n if (shift === 0) return widened\n\n /* The debt is only owed if the track has actually overflowed. A sliver on an\n * otherwise empty track - one segment, or several with gaps between them -\n * grows into room nobody was using, and nothing needs to give way. */\n const end = widened.at(-1)!\n const overflow = end.left + end.width - 100\n if (overflow <= 0) return widened\n\n /* Who can pay: everything above the floor, by however much it has to spare.\n * Taken in proportion, so one long stretch is not singled out to absorb the\n * whole debt while its neighbour keeps its exact width. */\n const spare = widened.reduce((sum, s) => sum + (s.widened ? 0 : Math.max(0, s.width - floor)), 0)\n\n /* Nobody can pay: every segment is at or under the floor, and the track is\n * genuinely too crowded to draw honestly at this width. The floor loses -\n * a bar running off its own end is worse than slivers too thin to see. */\n if (spare <= 0) return raw.map((segment) => ({ ...segment, widened: false }))\n\n let paid = 0\n return widened.map((segment) => {\n const left = segment.left - paid\n if (segment.widened) return { ...segment, left }\n\n const contribution = (Math.max(0, segment.width - floor) / spare) * overflow\n paid += contribution\n return { left, width: segment.width - contribution, widened: false }\n })\n}\n\n/** Where a marker sits on the same scale, as a percentage, or `null` when it\n * falls outside the track.\n *\n * Outside rather than clamped: a marker pinned to the edge says \"here, at the\n * very end\", which is a different statement from \"not on this track at all\". */\nexport function markerAt(value: number, from: number, to: number): number | null {\n const span = to - from\n if (!(span > 0)) return null\n if (value < from || value > to) return null\n return ((value - from) / span) * 100\n}\n"
1552
+ }
1553
+ ]
1554
+ },
1555
+ {
1556
+ "name": "track",
1557
+ "type": "registry:ui",
1558
+ "title": "Track",
1559
+ "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
+ "dependencies": [
1561
+ "class-variance-authority",
1562
+ "dowel-ui@^0.24.0"
1563
+ ],
1564
+ "registryDependencies": [
1565
+ "https://lacodda.github.io/dowel/r/track-segments.json"
1566
+ ],
1567
+ "files": [
1568
+ {
1569
+ "path": "ui/track.tsx",
1570
+ "target": "@ui/track.tsx",
1571
+ "type": "registry:ui",
1572
+ "content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { markerAt, place, type SegmentInput } from './track-segments'\n\n/*\n * A bar divided into stretches, with something standing somewhere along it.\n *\n * Two products had written this independently and arrived at the same\n * construction - a rounded track, segments positioned absolutely by percent, a\n * floor under the segment width so a short one does not vanish - differing\n * only in what a segment meant. One drew the tiers of a rubric with the score\n * standing among them; the other drew a working day as alternating work and\n * breaks. Neither could be built from the other, and each knew something the\n * other did not: the tiers had the marker and the three-state reading of a\n * segment (passed, standing in, still ahead), the day had the minimum width\n * and the difference between an empty track and an unknown one.\n *\n * So it is one component, and the two are its two shapes:\n *\n * **spans** - stretches of a whole, each meaning something in its own right:\n * work and breaks, phases, occupancy. Adjacent or separated; gaps are the\n * track showing through.\n *\n * **thresholds** - a scale cut into bands, with a position on it. Here the\n * segments are contiguous by construction and the point is not the bands but\n * where you stand among them: \"nearly a clip\" is what the reader wants, and\n * a badge saying which band you are in cannot say it.\n *\n * The arithmetic is in `track-segments`, importable without React.\n *\n * What this does NOT do is own its own height in pixels, and that is\n * deliberate: it is `h-1.5` in one donor and `h-2.5` in the other because the\n * bar carries different weight on the two screens. What it does own is the\n * geometry inside itself - percentages of its own box, never of a parent's -\n * which is the part that broke when a consumer put a percentage-height chart\n * inside a flex row and every bar resolved to zero.\n */\n\nexport const trackVariants = cva('relative w-full overflow-hidden rounded-full', {\n variants: {\n size: {\n /* Beside text, where the bar is a detail of a line. */\n sm: 'h-1.5',\n /* On its own row, where the bar is the thing being read. */\n md: 'h-2.5',\n },\n },\n defaultVariants: { size: 'md' },\n})\n\nexport const trackSegmentVariants = cva('absolute top-0 h-full', {\n variants: {\n tone: {\n /* The subject: work done, the band you are standing in. */\n accent: 'bg-accent',\n /* Behind you, or secondary: a band already passed. Dimmed so the\n * current one carries the eye - a flat wash of \"reached\" over half the\n * bar says only that you are not at zero. */\n past: 'bg-accent/40',\n /* Ahead, or simply not the subject: a break, a band not yet reached. */\n idle: 'bg-line-2',\n /* Status, for a stretch that is itself a state rather than a quantity. */\n good: 'bg-good',\n warn: 'bg-warn',\n bad: 'bg-bad',\n },\n },\n defaultVariants: { tone: 'accent' },\n})\n\nexport type TrackTone = NonNullable<VariantProps<typeof trackSegmentVariants>['tone']>\n\nexport interface TrackSegment extends SegmentInput {\n /** Distinguishes this segment from its neighbours in the DOM. */\n key: string\n tone?: TrackTone\n /** What this stretch is, in words. Shown on hover, and the only place the\n * segment's meaning exists for a reader who cannot see the colours. */\n label?: string\n}\n\nexport interface TrackProps\n extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>,\n VariantProps<typeof trackVariants> {\n segments: TrackSegment[]\n /** The value the left edge stands for. Defaults to the earliest segment. */\n from?: number\n /** The value the right edge stands for. Defaults to the latest segment. */\n to?: number\n /** Where the position marker stands, on the same scale as the segments.\n * Omitted when there is no such thing - a day of work has no \"you are here\". */\n marker?: number\n /** What the whole bar says, for a reader who cannot see it. Required: the\n * segments are decoration to a screen reader, and their titles are not\n * announced in order. */\n label: string\n /** Override the floor under a segment's width - `0` draws everything exactly\n * to scale.\n *\n * The floor is right for a day of work, where a short break is a fact worth\n * seeing. It is wrong wherever the widths are being compared to each other,\n * because a widened segment is no longer to scale and a reader measuring by\n * eye would be measuring the floor. */\n minWidth?: number\n /** Separate adjacent segments with a hairline of the ground.\n *\n * On for thresholds, where the bands touch and the boundary between two\n * reached ones would otherwise be invisible. Off for spans, where a gap in\n * the data is meant to look different from a gap between two stretches. */\n divided?: boolean\n}\n\nexport function Track({\n segments,\n from,\n to,\n marker,\n label,\n minWidth,\n divided = false,\n size,\n className,\n ...props\n}: TrackProps) {\n const placed = place(segments, { from, to, minWidth })\n\n /* The marker rides the same scale as the segments, so it is resolved against\n * the same bounds rather than against its own reading of them. */\n const bounds = {\n from: from ?? Math.min(...segments.map((s) => s.start), Infinity),\n to: to ?? Math.max(...segments.map((s) => s.end), -Infinity),\n }\n const at = marker === undefined ? null : markerAt(marker, bounds.from, bounds.to)\n\n return (\n <div\n role=\"img\"\n aria-label={label}\n className={cn(trackVariants({ size }), 'bg-soft', className)}\n {...props}\n >\n {placed.map((geometry, index) => {\n const segment = segments[index]!\n return (\n <span\n key={segment.key}\n title={segment.label}\n style={{ left: `${geometry.left}%`, width: `${geometry.width}%` }}\n className={cn(\n trackSegmentVariants({ tone: segment.tone }),\n // The hairline is drawn in the page's own ground rather than in\n // a border colour, so it reads as a cut between two fills\n // instead of a third colour of its own.\n divided && index > 0 && 'border-l border-bg',\n )}\n />\n )\n })}\n\n {at !== null && (\n /* A dark core inside a light sheath, so the mark keeps its contrast\n * over the accent band it usually stands on as well as over the empty\n * road ahead. Centred on its position rather than starting at it: the\n * mark says \"here\", and a mark whose left edge is the position reads\n * as half a step further along than it is. */\n <span\n style={{ left: `${at}%` }}\n className=\"absolute top-1/2 h-[10px] w-[6px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-bg ring-2 ring-text\"\n />\n )}\n </div>\n )\n}\n\n/*\n * The labels under a track.\n *\n * Separate from the track because the two donors disagreed about whether there\n * are any - the day had none, the tiers had one per band - and because a caller\n * with three bands and a narrow column will want to drop them without giving\n * up the bar.\n */\nexport interface TrackScaleProps extends HTMLAttributes<HTMLDivElement> {\n children: ReactNode\n}\n\nexport function TrackScale({ className, ...props }: TrackScaleProps) {\n return (\n <div\n className={cn('flex justify-between text-[10px] text-faint', className)}\n {...props}\n />\n )\n}\n"
1573
+ }
1574
+ ]
1575
+ },
1292
1576
  {
1293
1577
  "name": "tree-rows",
1294
1578
  "type": "registry:ui",
@@ -1311,7 +1595,7 @@
1311
1595
  "title": "Tree-view",
1312
1596
  "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.",
1313
1597
  "dependencies": [
1314
- "dowel-ui@^0.22.0"
1598
+ "dowel-ui@^0.24.0"
1315
1599
  ],
1316
1600
  "registryDependencies": [
1317
1601
  "https://lacodda.github.io/dowel/r/tree-rows.json"
@@ -1331,7 +1615,7 @@
1331
1615
  "title": "Truncate",
1332
1616
  "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.",
1333
1617
  "dependencies": [
1334
- "dowel-ui@^0.22.0"
1618
+ "dowel-ui@^0.24.0"
1335
1619
  ],
1336
1620
  "registryDependencies": [],
1337
1621
  "files": [
@@ -1349,7 +1633,7 @@
1349
1633
  "title": "Virtual-list",
1350
1634
  "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.",
1351
1635
  "dependencies": [
1352
- "dowel-ui@^0.22.0"
1636
+ "dowel-ui@^0.24.0"
1353
1637
  ],
1354
1638
  "registryDependencies": [],
1355
1639
  "files": [