dowel-ui 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/dist/history.json +170 -0
- package/dist/palettes/atlas.json +1 -1
- package/dist/palettes/austeris.json +1 -1
- package/dist/palettes/dowel.json +1 -1
- package/dist/palettes/efema.json +1 -1
- package/dist/palettes/furca.json +1 -1
- package/dist/palettes/hilvan.json +1 -1
- package/dist/palettes/kasl-server.json +1 -1
- package/dist/palettes/kasl.json +1 -1
- package/dist/palettes/kilna.json +1 -1
- package/dist/palettes/lyrid.json +1 -1
- package/dist/palettes/lyrn.json +1 -1
- package/dist/palettes/midda.json +1 -1
- package/dist/palettes/nitid.json +1 -1
- package/dist/palettes/nooma.json +1 -1
- package/dist/palettes/rhapsod.json +1 -1
- package/dist/palettes/rigger.json +1 -1
- package/dist/palettes/scheda.json +1 -1
- package/dist/palettes/sefy.json +1 -1
- package/dist/palettes/turnout.json +1 -1
- package/dist/registry.json +180 -113
- package/dist/theme.css +25 -4
- package/package.json +1 -1
package/dist/registry.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"path": "dowel/theme.css",
|
|
15
15
|
"target": "~/dowel/theme.css",
|
|
16
16
|
"type": "registry:file",
|
|
17
|
-
"content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /*\n * A step between the steps.\n *\n * The spacing scale runs in fours - 2, 4, 6, 8 - and four different\n * primitives independently reached past it for three pixels: the gap\n * between segments of a rating and of an axis, the gap between cells of a\n * heatmap, and how far a tooltip's arrow tucks under its popup. Four hands\n * arriving at the same number is not four accidents; it is a step the scale\n * was missing, and each of them wrote `[3px]` because there was nothing to\n * name.\n *\n * It exists for hairline gaps between things that are themselves small -\n * where two pixels reads as touching and four as separate objects. Nothing\n * larger belongs here: this is the bottom of the scale, not a licence to\n * measure by eye.\n */\n --spacing-hair: 3px;\n\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 * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n\n /*\n * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * The rail with only its icons: `NavRail collapsed`. 56 is a 36px entry -\n * the size of an icon button in the bar above it - with 10px of air on each\n * side, which is the same inset the wide rail gives its entries. Narrower,\n * and the active entry's tint touches the rail's border; wider, and the\n * column reads as a sidebar that forgot its words.\n */\n --spacing-rail-compact: 56px;\n\n /*\n * A list beside what is open in it: `ListDetail`'s column of rows.\n *\n * Wider than the rail on purpose. The rail holds a destination per line -\n * one word and an icon - and a list holds a title with a line under it and\n * a date at the end, which at 216px is cut to three words. kilna drew six\n * such screens with five widths and three gaps before settling on two\n * widths, this and the rail's; 264 is the one its rows read at.\n */\n --spacing-list: 264px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n/*\n * `caption` - the small uppercase label: a field's name, a panel's section, a\n * group of menu items, a rail's group.\n *\n * One role, and the set wrote it seven ways: with and without `font-medium`,\n * with and without a weight at all, so a Field's label and the SectionLabel\n * above it were two weights of the same thing. kilna's screens added\n * thirty-one more by hand. The weight is 600, the mockup's `.lbl` - at 10px\n * a caption lighter than that reads as a smudge rather than a word.\n *\n * A utility rather than a component because it is a role text plays inside\n * things that are already components: a `<legend>`, a `<th>`, a `<label>`.\n * Its colour is `dim`, not `faint`. `faint` is verified against the grounds at\n * body size; at 10px a live axe run measured it at 3.45:1 on a raised surface\n * in the light theme and 3.16:1 in the dark, against AA's 4.5:1 - a caption\n * nobody can read is a caption doing nothing. `dim` passes in both. That\n * `faint` fails at every small size, not only here, is still the contrast\n * work's to gate.\n */\n@utility caption {\n @apply text-2xs font-semibold uppercase tracking-caption text-dim;\n}\n\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\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 /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
|
|
17
|
+
"content": "/*\n * dowel theme - the token vocabulary every product of the lacodda line shares.\n *\n * The vocabulary comes from the products themselves: kilna and kasl-server\n * already ship the same token names (bg / raise / soft / line / text / dim /\n * accent / good / warn / bad / info) and differ only in values. That is the\n * contract this file freezes. Names are the mockup's own words rather than\n * stock component-library names, so a screen can be checked against a mockup\n * in the mockup's words.\n *\n * Two things are parametric, and everything else is derived from them:\n *\n * --accent-base the product's hue from the brand-line registry\n * --neutral-base the hue the greys are tinted with (the accent, by default)\n *\n * Tinting the neutrals is not decoration - it is what the two live products do\n * by hand: kilna's greys lean magenta, kasl-server's lean gold. Here that lean\n * is one declaration instead of thirty hand-picked hex values.\n *\n * Soft variants are mixed from their own base with `color-mix`, so a product\n * that overrides `--accent-base` gets a matching `--accent-soft` for free and\n * cannot pick one that disagrees with it.\n *\n * Theme selection: no class on the root element follows the operating system,\n * an explicit `light` or `dark` class pins the theme. Components never use\n * `dark:` utilities - every colour goes through a token, and the theme swaps\n * the token underneath.\n */\n\n:root {\n /* The two parameters. `--accent-base` is overridden per product by an accent\n * file; `--neutral-base` follows it unless a product says otherwise. */\n --accent-base: #e8862d;\n --neutral-base: var(--accent-base);\n\n /* Ink and ground of the dark theme, before the neutral tint is mixed in.\n * Kept as their own tokens so the tint amount is the only thing that\n * changes when a product wants greyer or warmer chrome. */\n --ground: #131316;\n --ink: #ece9ef;\n\n /* How much of `--neutral-base` bleeds into the greys. The live products sit\n * at roughly this much: enough that the chrome belongs to the product,\n * little enough that it still reads as grey. */\n --neutral-tint: 6%;\n --neutral-tint-strong: 9%;\n\n color-scheme: dark;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), #1c1c21);\n\n /* Surfaces that lift by translucency rather than by their own colour: they\n * must work over `--bg` and over `--raise` alike. */\n --soft: rgb(255 255 255 / 0.045);\n --softer: rgb(255 255 255 / 0.025);\n --line: rgb(255 255 255 / 0.08);\n --line-2: rgb(255 255 255 / 0.15);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #a7a2ad);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #6e6a76);\n\n --accent: var(--accent-base);\n /* The hover/active partner: lighter on dark, where the ground is what the\n * accent has to separate from. */\n --accent-2: color-mix(in oklab, white 22%, var(--accent-base));\n --accent-soft: color-mix(in oklab, var(--accent-base) 16%, transparent);\n\n /* Status hues are the line's own and do not follow the product accent: a\n * green that shifted per product would stop meaning \"good\". Meaning never\n * rests on colour alone - a badge carries an icon and a word - so these\n * exist for emphasis, not as the message. */\n --good: #45d18f;\n --warn: #e8b13f;\n --bad: #ef6a6a;\n --info: #4cc4e0;\n --good-soft: color-mix(in oklab, var(--good) 14%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 14%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 15%, transparent);\n\n /* Series colours, for charts.\n *\n * Like the status hues and unlike everything else here, these do NOT follow\n * the product accent - and that is the whole decision. A series is a\n * property of the data, not of the product showing it; a palette derived\n * from the accent would paint the same series differently in kilna and in\n * kasl-server, which is the one thing a series colour must never do.\n *\n * Deriving them from the accent was measured, not guessed: rotating the hue\n * by fixed angles put the best candidate at Delta-E 12.1 from the product's\n * own accent with adjacent pairs at 9.8, and threw 46 of 152 colours outside\n * sRGB. The fixed palette does better on both counts.\n *\n * The cost of fixing them is stated plainly: 13 of the line's 19 accents sit\n * within Delta-E 8 of some slot (lyrid 2.7, atlas 3.1, turnout 3.2). A chart\n * shows one accent, so this is survivable - but it is exactly why identity is\n * carried by the legend and by direct labels, never by hue on its own. The\n * components of this block enforce that; it is not advice.\n *\n * Assigned in order, 1..8, and never cycled: a ninth series folds into\n * \"other\", or the chart becomes small multiples. The order is the\n * colour-blind-safety mechanism - the worst adjacent pair is Delta-E 8.4\n * under simulated protanopia - so re-ordering these breaks a gate.\n *\n * Slot 8 is the line's own value rather than the reference palette's. The\n * reference red sat Delta-E 1.9 from `--bad`, which would have let a series\n * impersonate a status; this one clears every status by 15 and costs\n * nothing, the worst adjacent pair being unchanged.\n *\n * Some slots sit below 3:1 against the surface. That is allowed only because\n * the relief ships with them: visible values, or the table view.\n */\n --series-1: #3987e5;\n --series-2: #d95926;\n --series-3: #199e70;\n --series-4: #c98500;\n --series-5: #d55181;\n --series-6: #008300;\n --series-7: #9085e9;\n --series-8: #a3215a;\n\n /* Magnitude rather than identity: one hue, light to dark. A heatmap cell\n * reads this. The lightest step is allowed to recede toward the surface,\n * because \"near zero\" should; an ordered-but-discrete scale (tiers, funnel\n * stages) starts at 300 instead, where the contrast still holds. */\n --scale-100: #0d366b;\n --scale-200: #184f95;\n --scale-300: #256abf;\n --scale-400: #2a78d6;\n --scale-500: #3987e5;\n --scale-600: #5598e7;\n --scale-700: #86b6ef;\n\n /* The chart's own furniture. A gridline that competes with the data is drawn\n * wrong, so it is quieter than `--line`; the axis is the one that may be\n * seen. */\n --chart-grid: rgb(255 255 255 / 0.06);\n --chart-axis: rgb(255 255 255 / 0.14);\n\n /* Heat, for a grid of cells where colour is the only thing carrying the\n * value - a year of activity, a month of a team's hours.\n *\n * Five discrete steps rather than the seven of `--scale-*`, and a separate\n * set rather than a slice of them, because the two answer different\n * questions. `--scale-*` is sequential: a continuous magnitude, where the\n * lightest step means \"near zero\" and may recede toward the surface.\n * Heat is ordinal: five shades a reader tells apart at a glance and matches\n * against a legend, which needs wider gaps between them (no slice of\n * `--scale-*` clears the 0.06 lightness step - they sit 0.047 apart) and a\n * bottom step that stays distinct from an empty cell.\n *\n * That last one is the whole point. A heatmap cell has more meanings than a\n * number: nothing recorded, a day still running, a day outside the data, and\n * a value. If the faintest value looks like the empty cell, the grid says\n * somebody did nothing on a day nobody reported - and `--heat-1` clears the\n * empty square by 2.1:1 so it cannot.\n *\n * Fixed, like the series and unlike almost everything else here. Derived\n * from the accent - as the first consumer did - the faintest step sat 1.3:1\n * from the empty cell for half the line, and pinning lightness instead ran\n * out of sRGB: the darkest accents cannot reach the top of the ramp at their\n * own chroma.\n */\n --heat-1: #28528e;\n --heat-2: #3a65a3;\n --heat-3: #4d78b7;\n --heat-4: #608ccd;\n --heat-5: #73a0e2;\n\n /* Syntax, for code shown inside a product.\n *\n * Eight kinds rather than the fifty a highlighter's theme names, because\n * these are the distinctions that survive across languages: a keyword, a\n * literal string, a number, a comment, an identifier, a type, punctuation,\n * and the metadata around the code (a decorator, an attribute, a prompt).\n * Anything finer is a grammar's own vocabulary and does not travel.\n *\n * Fixed, like the series and the heat ramps, and for the same reason: code\n * is not a property of the product showing it, and `if` should not be\n * magenta in kilna and cobalt in kasl-server.\n *\n * Chosen against the surface code actually sits on - `--soft` over `--bg`,\n * which is what CodeBlock and `.prose pre` draw - and every slot clears\n * 4.5:1 AS TEXT in both themes. That threshold, not the 3:1 the series are\n * held to, is the whole reason these exist separately: the series palette\n * was measured here first and five of its eight light values sat under 3:1,\n * one at 1.90:1. A filled bar can be pale; a 12px glyph cannot.\n *\n * These are deliberately NOT held to the colour-blind floor the series are.\n * A series colour is the identity of its mark - lose the hue and the data is\n * gone. Syntax colour is a second reading of what the text already says in\n * full: a keyword is a keyword by its spelling, and CodeBlock's default\n * renders no colour at all and stays readable. `syntax.test.ts` says so in\n * place, so the omission is not mistaken for an oversight.\n *\n * `comment` is the quietest on purpose - it is the one kind a reader is\n * meant to be able to skip - and still clears the threshold.\n */\n --syntax-keyword: #c792ea;\n --syntax-string: #9ccc65;\n --syntax-number: #f78c6c;\n --syntax-comment: #9e99a6;\n --syntax-name: #82aaff;\n --syntax-type: #4dd0b1;\n --syntax-punctuation: #c4bfcb;\n --syntax-meta: #f0a868;\n\n /* Elevation, three steps. The products had one shadow and used it for\n * everything that leaves the flow - a toast, a dropdown and a modal all\n * floated by the same amount, so a modal never felt further away than the\n * menu it covered. The three are the steps.\n *\n * Two layers each, and that is what makes them read as depth on a dark\n * surface rather than as a stain. One big soft blur at high opacity is what\n * these were - `0 24px 60px` of 55% black - and against a near-black page\n * the wide skirt never resolves into shade: it just darkens a ring of\n * background around the panel, which a pilot photographed and called an ugly\n * dark contour. Depth is read from the CONTACT shadow, the tight one right\n * under the edge; the ambient layer only has to hint that there is room\n * under the thing. So each step is a tight, slightly stronger layer plus a\n * wider, much weaker one, and the totals come down. */\n --shadow-lift: 0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22);\n --shadow-raise: 0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26);\n --shadow-float: 0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32);\n}\n\n/*\n * Light theme, twice: once for the operating system's preference, once for the\n * explicit `light` class. The declarations are identical - only the selector\n * differs - so that a product can pin a theme against the system setting.\n */\n@media (prefers-color-scheme: light) {\n :root:not(.dark) {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* Tinted, and translucent - which took two goes to get right.\n *\n * `color-mix` mixes the alpha along with the colour, so mixing 60% of an\n * opaque accent into a 5%-opaque grey gives a surface 62% opaque: twelve\n * times denser than the hairline it was meant to be. It went unnoticed for\n * ten versions because `bg-soft` was only ever used for a hover, where a\n * flash of colour reads as feedback rather than as a mistake. The first\n * component to sit on it permanently - Alert - made it obvious.\n *\n * `oklch(from … / alpha)` keeps the alpha out of the mix: the hue comes\n * from the tinted colour, the transparency is stated. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* On a light ground the accent has to darken to stay legible as text and\n * as a fill. It darkens *to* a lightness rather than *by* an amount: how\n * far a hue has to travel depends on where it starts, and a fixed step\n * that suits magenta leaves lime and gold short. Pinning the lightness and\n * keeping the hue and chroma clears 5:1 for every accent in the line. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n }\n}\n\n:root.light {\n color-scheme: light;\n\n --ground: #f6f5f7;\n --ink: #232027;\n\n --bg: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ground));\n --raise: #ffffff;\n\n /* The same surfaces as above; see the note there. */\n --soft: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.05);\n --softer: oklch(from color-mix(in oklab, var(--neutral-base) 45%, #181420) l c h / 0.03);\n --line: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.11);\n --line-2: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.2);\n\n --text: color-mix(in oklab, var(--neutral-base) var(--neutral-tint), var(--ink));\n --dim: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #63606b);\n --faint: color-mix(in oklab, var(--neutral-base) var(--neutral-tint-strong), #8a8692);\n\n /* Darkened to a lightness, not by an amount - see the note above. */\n --accent: oklch(from var(--accent-base) 0.5 c h);\n --accent-2: oklch(from var(--accent-base) 0.4 c h);\n --accent-soft: color-mix(in oklab, var(--accent-base) 12%, transparent);\n\n --good: #0c8554;\n --warn: #9a6b0c;\n --bad: #c93b3b;\n --info: #0d7f9c;\n --good-soft: color-mix(in oklab, var(--good) 12%, transparent);\n --warn-soft: color-mix(in oklab, var(--warn) 14%, transparent);\n --bad-soft: color-mix(in oklab, var(--bad) 12%, transparent);\n --info-soft: color-mix(in oklab, var(--info) 11%, transparent);\n\n /* Series and scale, stepped for the light surface - the same eight hues\n * chosen again against this ground, not the dark values lightened. The dark\n * block says why these do not follow the product accent. */\n --series-1: #2a78d6;\n --series-2: #eb6834;\n --series-3: #1baf7a;\n --series-4: #eda100;\n --series-5: #e87ba4;\n --series-6: #008300;\n --series-7: #4a3aa7;\n --series-8: #85284d;\n\n --scale-100: #cde2fb;\n --scale-200: #9ec5f4;\n --scale-300: #6da7ec;\n --scale-400: #3987e5;\n --scale-500: #2a78d6;\n --scale-600: #1c5cab;\n --scale-700: #104281;\n\n --chart-grid: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.08);\n --chart-axis: oklch(from color-mix(in oklab, var(--neutral-base) 30%, #181420) l c h / 0.18);\n\n /* The same five steps chosen again for the light ground, running the other\n * way: here \"more\" is darker. See the dark block for why heat is its own\n * ramp and not a slice of `--scale-*`. */\n --heat-1: #84a8fd;\n --heat-2: #7193e7;\n --heat-3: #5e7fd1;\n --heat-4: #4c6cbb;\n --heat-5: #3b58a6;\n\n /* The same eight kinds chosen again for the light ground - not the dark\n * values darkened. Each clears 4.5:1 as text against `--soft` over this\n * theme's `--bg`; the dark block says why syntax is its own palette and\n * why it is not held to the colour-blind floor. */\n --syntax-keyword: #7c3aad;\n --syntax-string: #276c2b;\n --syntax-number: #b5451b;\n --syntax-comment: #6f6b78;\n --syntax-name: #1a5fb4;\n --syntax-type: #00695c;\n --syntax-punctuation: #4a4753;\n --syntax-meta: #9c4a00;\n\n --shadow-lift: 0 2px 8px rgb(30 24 38 / 0.08);\n --shadow-raise: 0 10px 30px rgb(30 24 38 / 0.14);\n --shadow-float: 0 24px 60px rgb(30 24 38 / 0.18);\n}\n\n/*\n * `--on-accent` - what sits on top of an accent fill.\n *\n * A light accent (gold, lime, amber) needs dark glyphs; a dark one needs\n * white. The live products picked this by hand and wrote the answer into the\n * theme; here the theme works it out, by the same rule the brand-line S tile\n * uses.\n *\n * `contrast-color()` is the direct way to say it and is used where supported.\n * The fallback covers browsers that lack it. Relative colour syntax exposes\n * the accent's own lightness as `l`; `clamp()` turns that into a hard switch,\n * because the multiplication drives the middle term far past either bound\n * everywhere except within a hair of the threshold. Below it the accent is\n * dark and the result is 1 (white); above it, 0 (black). Chroma is dropped to\n * zero, so what comes out is neutral rather than a tinted grey.\n *\n * The threshold is 0.58, and it is deliberately far below the midpoint an eye\n * would guess. Contrast is not symmetric about it: a mid-lightness colour is\n * still much closer to white than to black in luminance, so black wins well\n * before the colour looks light. Checked against all fourteen accents of the\n * line - every one of them reads better with dark glyphs, the closest being\n * cobalt at 4.86:1 against 4.32:1 for white. A higher threshold is what puts\n * white text on magenta at 3.6:1, which is the defect this rule exists to\n * prevent.\n */\n:root {\n --on-accent: oklch(from var(--accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n\n /*\n * The same question for the status fills, and it has to be asked separately:\n * `--on-accent` is derived from the accent, so using it on a `--warn` fill\n * is only ever right by coincidence. The line's first consumer did exactly\n * that - a count on a yellow badge, drawn in white at 1.95:1 - and it read\n * as correct for as long as the product happened to pin white.\n *\n * The status hues do not follow the product accent, so these four are the\n * same for every product; they are still derived rather than written down,\n * because the status colours themselves change between the themes.\n */\n --on-good: oklch(from var(--good) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-warn: oklch(from var(--warn) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-bad: oklch(from var(--bad) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n --on-info: oklch(from var(--info) clamp(0, (0.58 - l) * 1000, 1) 0 0);\n}\n\n@supports (color: contrast-color(red)) {\n :root {\n --on-accent: contrast-color(var(--accent));\n --on-good: contrast-color(var(--good));\n --on-warn: contrast-color(var(--warn));\n --on-bad: contrast-color(var(--bad));\n --on-info: contrast-color(var(--info));\n }\n}\n\n/*\n * The Tailwind 4 surface. `--color-*: initial` drops the stock palette on\n * purpose: a raw `bg-zinc-800` in a product should not compile, because the\n * only colours that exist here are the line's own.\n */\n@theme inline {\n --color-*: initial;\n --color-bg: var(--bg);\n --color-raise: var(--raise);\n --color-soft: var(--soft);\n --color-softer: var(--softer);\n --color-line: var(--line);\n --color-line-2: var(--line-2);\n --color-text: var(--text);\n --color-dim: var(--dim);\n --color-faint: var(--faint);\n --color-accent: var(--accent);\n --color-accent-2: var(--accent-2);\n --color-accent-soft: var(--accent-soft);\n --color-on-accent: var(--on-accent);\n --color-on-good: var(--on-good);\n --color-on-warn: var(--on-warn);\n --color-on-bad: var(--on-bad);\n --color-on-info: var(--on-info);\n --color-good: var(--good);\n --color-good-soft: var(--good-soft);\n --color-warn: var(--warn);\n --color-warn-soft: var(--warn-soft);\n --color-bad: var(--bad);\n --color-bad-soft: var(--bad-soft);\n --color-info: var(--info);\n --color-info-soft: var(--info-soft);\n\n /* Charts. `bg-series-3`, `fill-series-3`, `stroke-scale-500` - the same\n * utilities the rest of the vocabulary gets, so a chart is written in\n * tokens like everything else and `dowel/no-raw-color` can hold it to\n * that. */\n --color-series-1: var(--series-1);\n --color-series-2: var(--series-2);\n --color-series-3: var(--series-3);\n --color-series-4: var(--series-4);\n --color-series-5: var(--series-5);\n --color-series-6: var(--series-6);\n --color-series-7: var(--series-7);\n --color-series-8: var(--series-8);\n --color-scale-100: var(--scale-100);\n --color-scale-200: var(--scale-200);\n --color-scale-300: var(--scale-300);\n --color-scale-400: var(--scale-400);\n --color-scale-500: var(--scale-500);\n --color-scale-600: var(--scale-600);\n --color-scale-700: var(--scale-700);\n --color-chart-grid: var(--chart-grid);\n --color-chart-axis: var(--chart-axis);\n --color-heat-1: var(--heat-1);\n --color-heat-2: var(--heat-2);\n --color-heat-3: var(--heat-3);\n --color-heat-4: var(--heat-4);\n --color-heat-5: var(--heat-5);\n\n /* Syntax, so a highlighted token is written `text-syntax-keyword` like every\n * other colour in the system, and `dowel/no-raw-color` can hold it to that.\n * A highlighter's own stylesheet - which writes hex values into class names\n * of its own choosing - is what this replaces. */\n --color-syntax-keyword: var(--syntax-keyword);\n --color-syntax-string: var(--syntax-string);\n --color-syntax-number: var(--syntax-number);\n --color-syntax-comment: var(--syntax-comment);\n --color-syntax-name: var(--syntax-name);\n --color-syntax-type: var(--syntax-type);\n --color-syntax-punctuation: var(--syntax-punctuation);\n --color-syntax-meta: var(--syntax-meta);\n\n /* Kept because they are not palette choices: a hairline is `transparent`,\n * an SVG follows `currentColor`, and pure black and white are what an\n * overlay scrim and a print sheet are made of. */\n --color-transparent: transparent;\n --color-current: currentColor;\n --color-white: #fff;\n --color-black: #000;\n\n /*\n * Type. System stacks on purpose: a downloaded face costs a network round\n * trip before the first word appears, and the line's products are desktop\n * tools where the operating system's own face is the one the user already\n * reads everything else in.\n */\n --font-sans: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;\n --font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Consolas, monospace;\n\n /*\n * Radius. Taken from what the products actually draw, not from a ratio:\n * `rounded-[9px]` appears twenty times and `rounded-[10px]` twelve, because\n * a control and the primary button were tuned by eye and then copied. The\n * scale keeps the cluster they landed in and gives it names.\n *\n * `md` is the control radius - inputs, buttons, list rows. That the primary\n * button was one pixel rounder than every other variant is not preserved:\n * the products differ from themselves there, and buttons of the same size\n * sitting side by side should not have mismatched corners.\n */\n --radius-xs: 4px;\n --radius-sm: 6px;\n --radius-md: 9px;\n --radius-lg: 12px;\n --radius-xl: 16px;\n --radius-2xl: 20px;\n\n /*\n * A radius nested inside another has to be smaller by the gap between them,\n * or the inner corner looks wrong against the outer one. The products did\n * this by hand once - 18px outside, 17px inside - and nowhere else.\n */\n --radius-inner: calc(var(--radius-lg) - 1px);\n\n /*\n * Type scale. The products live between 10px and 14px: `text-sm` and\n * `text-xs` together account for nine tenths of every size in both, and the\n * rest scattered across 9, 9.5, 10, 10.5, 11, 11.5, 12.5 and 13 - nine steps\n * inside four pixels, which no eye distinguishes and no reason justifies.\n * This is the same range with the noise removed.\n */\n --text-2xs: 10px;\n --text-2xs--line-height: 14px;\n --text-xs: 11px;\n --text-xs--line-height: 15px;\n --text-sm: 12px;\n --text-sm--line-height: 16px;\n --text-base: 14px;\n --text-base--line-height: 20px;\n --text-lg: 16px;\n --text-lg--line-height: 22px;\n --text-xl: 18px;\n --text-xl--line-height: 24px;\n --text-2xl: 21px;\n --text-2xl--line-height: 28px;\n\n /* Weights. `semibold` is what both products use for anything emphasised;\n * `bold` appears in neither, and the one `font-[650]` in a page title is the\n * kind of value a scale exists to absorb. */\n --font-weight-normal: 400;\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n\n /* Tracking. The uppercase caption is the only place the products track at\n * all - and they do it at 0.08em in six files and 0.09em in three, a\n * difference nobody can see. One name settles it. */\n --tracking-caption: 0.085em;\n --tracking-tight: -0.01em;\n\n /*\n * A step between the steps.\n *\n * The spacing scale runs in fours - 2, 4, 6, 8 - and four different\n * primitives independently reached past it for three pixels: the gap\n * between segments of a rating and of an axis, the gap between cells of a\n * heatmap, and how far a tooltip's arrow tucks under its popup. Four hands\n * arriving at the same number is not four accidents; it is a step the scale\n * was missing, and each of them wrote `[3px]` because there was nothing to\n * name.\n *\n * It exists for hairline gaps between things that are themselves small -\n * where two pixels reads as touching and four as separate objects. Nothing\n * larger belongs here: this is the bottom of the scale, not a licence to\n * measure by eye.\n */\n --spacing-hair: 3px;\n\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 * Durations, as the namespace Tailwind's `duration-*` reads, so\n * `duration-quick` is a class. The steps are declared once, as `--duration-*`\n * further down; these only name them for the compiler.\n *\n * Until v0.37 they were not here, and the comment beside the steps said a\n * named duration could not be a class. It can - the namespace is\n * `--transition-duration-*` - and eight primitives had been writing\n * `duration-quick` and `duration-base` all along: classes that compiled to\n * nothing, so every one of those transitions ran on Tailwind's 150ms. The\n * motion gate now compiles every class of motion the set writes.\n *\n * The default is a step too. A bare `transition-colors` - most of the set's\n * transitions - takes it, and 150ms was the one duration in the set nobody\n * had chosen.\n */\n --transition-duration-quick: var(--duration-quick);\n --transition-duration-base: var(--duration-base);\n --transition-duration-slow: var(--duration-slow);\n --default-transition-duration: var(--duration-quick);\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 * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n\n /*\n * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * The rail with only its icons: `NavRail collapsed`. 56 is a 36px entry -\n * the size of an icon button in the bar above it - with 10px of air on each\n * side, which is the same inset the wide rail gives its entries. Narrower,\n * and the active entry's tint touches the rail's border; wider, and the\n * column reads as a sidebar that forgot its words.\n */\n --spacing-rail-compact: 56px;\n\n /*\n * A list beside what is open in it: `ListDetail`'s column of rows.\n *\n * Wider than the rail on purpose. The rail holds a destination per line -\n * one word and an icon - and a list holds a title with a line under it and\n * a date at the end, which at 216px is cut to three words. kilna drew six\n * such screens with five widths and three gaps before settling on two\n * widths, this and the rail's; 264 is the one its rows read at.\n */\n --spacing-list: 264px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n/*\n * `caption` - the small uppercase label: a field's name, a panel's section, a\n * group of menu items, a rail's group.\n *\n * One role, and the set wrote it seven ways: with and without `font-medium`,\n * with and without a weight at all, so a Field's label and the SectionLabel\n * above it were two weights of the same thing. kilna's screens added\n * thirty-one more by hand. The weight is 600, the mockup's `.lbl` - at 10px\n * a caption lighter than that reads as a smudge rather than a word.\n *\n * A utility rather than a component because it is a role text plays inside\n * things that are already components: a `<legend>`, a `<th>`, a `<label>`.\n * Its colour is `dim`, not `faint`. `faint` is verified against the grounds at\n * body size; at 10px a live axe run measured it at 3.45:1 on a raised surface\n * in the light theme and 3.16:1 in the dark, against AA's 4.5:1 - a caption\n * nobody can read is a caption doing nothing. `dim` passes in both. That\n * `faint` fails at every small size, not only here, is still the contrast\n * work's to gate.\n */\n@utility caption {\n @apply text-2xs font-semibold uppercase tracking-caption text-dim;\n}\n\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\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 * Declared here rather than in `@theme`, so the token keeps its name - the\n * `@theme` block above maps each to `--transition-duration-*`, which is what\n * makes `duration-quick` a class. Inside an arbitrary transition they are\n * read directly: `[transition:opacity_var(--duration-quick)_var(--ease-out)]`.\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 /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\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; }"
|
|
@@ -307,7 +307,7 @@
|
|
|
307
307
|
"title": "AboutPlate",
|
|
308
308
|
"description": "The about-plate primitive.",
|
|
309
309
|
"dependencies": [
|
|
310
|
-
"dowel-ui@^0.
|
|
310
|
+
"dowel-ui@^0.37.0"
|
|
311
311
|
],
|
|
312
312
|
"registryDependencies": [
|
|
313
313
|
"https://lacodda.github.io/dowel/r/product-mark.json"
|
|
@@ -328,7 +328,7 @@
|
|
|
328
328
|
"description": "A stack of collapsible sections that share one rule about how many can be open at once - an FAQ, the groups in a long settings page, the filters in a sidebar. Each section is `Collapsible`'s shape (a header, a trigger, a panel) repeated inside a group that also decides `single` or `multiple`, which is the one thing a lone `Collapsible` has no opinion about because it has nothing to be exclusive with.",
|
|
329
329
|
"dependencies": [
|
|
330
330
|
"@base-ui/react",
|
|
331
|
-
"dowel-ui@^0.
|
|
331
|
+
"dowel-ui@^0.37.0"
|
|
332
332
|
],
|
|
333
333
|
"registryDependencies": [
|
|
334
334
|
"https://lacodda.github.io/dowel/r/collapsible.json"
|
|
@@ -350,7 +350,7 @@
|
|
|
350
350
|
"dependencies": [
|
|
351
351
|
"@base-ui/react",
|
|
352
352
|
"class-variance-authority",
|
|
353
|
-
"dowel-ui@^0.
|
|
353
|
+
"dowel-ui@^0.37.0"
|
|
354
354
|
],
|
|
355
355
|
"registryDependencies": [],
|
|
356
356
|
"files": [
|
|
@@ -369,7 +369,7 @@
|
|
|
369
369
|
"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.",
|
|
370
370
|
"dependencies": [
|
|
371
371
|
"class-variance-authority",
|
|
372
|
-
"dowel-ui@^0.
|
|
372
|
+
"dowel-ui@^0.37.0"
|
|
373
373
|
],
|
|
374
374
|
"registryDependencies": [
|
|
375
375
|
"https://lacodda.github.io/dowel/r/activity-weeks.json"
|
|
@@ -389,7 +389,7 @@
|
|
|
389
389
|
"title": "ActivityLegend",
|
|
390
390
|
"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.",
|
|
391
391
|
"dependencies": [
|
|
392
|
-
"dowel-ui@^0.
|
|
392
|
+
"dowel-ui@^0.37.0"
|
|
393
393
|
],
|
|
394
394
|
"registryDependencies": [
|
|
395
395
|
"https://lacodda.github.io/dowel/r/activity-heatmap.json",
|
|
@@ -427,7 +427,7 @@
|
|
|
427
427
|
"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.",
|
|
428
428
|
"dependencies": [
|
|
429
429
|
"class-variance-authority",
|
|
430
|
-
"dowel-ui@^0.
|
|
430
|
+
"dowel-ui@^0.37.0"
|
|
431
431
|
],
|
|
432
432
|
"registryDependencies": [],
|
|
433
433
|
"files": [
|
|
@@ -446,7 +446,7 @@
|
|
|
446
446
|
"description": "The frame a product draws once and then never thinks about: a bar across the top, a rail down the left, and the screen in the corner they leave. It is three boxes and a grid, which is exactly why every product wrote its own - and why every one of them got the same two things wrong before getting them right.",
|
|
447
447
|
"dependencies": [
|
|
448
448
|
"class-variance-authority",
|
|
449
|
-
"dowel-ui@^0.
|
|
449
|
+
"dowel-ui@^0.37.0"
|
|
450
450
|
],
|
|
451
451
|
"registryDependencies": [],
|
|
452
452
|
"files": [
|
|
@@ -465,7 +465,7 @@
|
|
|
465
465
|
"description": "A person, in the space of a word. Every screen that lists people needs one, and the three things that go wrong with it are always the same:\n * - **The picture fails to load** and a broken-image glyph appears where a face was. The fallback is not a nicety; it is the state this component spends most of its life in, because half the people in any list have no picture at all.",
|
|
466
466
|
"dependencies": [
|
|
467
467
|
"class-variance-authority",
|
|
468
|
-
"dowel-ui@^0.
|
|
468
|
+
"dowel-ui@^0.37.0"
|
|
469
469
|
],
|
|
470
470
|
"registryDependencies": [],
|
|
471
471
|
"files": [
|
|
@@ -484,7 +484,7 @@
|
|
|
484
484
|
"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.",
|
|
485
485
|
"dependencies": [
|
|
486
486
|
"class-variance-authority",
|
|
487
|
-
"dowel-ui@^0.
|
|
487
|
+
"dowel-ui@^0.37.0"
|
|
488
488
|
],
|
|
489
489
|
"registryDependencies": [],
|
|
490
490
|
"files": [
|
|
@@ -503,7 +503,7 @@
|
|
|
503
503
|
"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.",
|
|
504
504
|
"dependencies": [
|
|
505
505
|
"class-variance-authority",
|
|
506
|
-
"dowel-ui@^0.
|
|
506
|
+
"dowel-ui@^0.37.0"
|
|
507
507
|
],
|
|
508
508
|
"registryDependencies": [],
|
|
509
509
|
"files": [
|
|
@@ -522,7 +522,7 @@
|
|
|
522
522
|
"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.",
|
|
523
523
|
"dependencies": [
|
|
524
524
|
"class-variance-authority",
|
|
525
|
-
"dowel-ui@^0.
|
|
525
|
+
"dowel-ui@^0.37.0"
|
|
526
526
|
],
|
|
527
527
|
"registryDependencies": [],
|
|
528
528
|
"files": [
|
|
@@ -541,7 +541,7 @@
|
|
|
541
541
|
"description": "One thing on a day of a month view, in one line: a release, a lesson, a payment that falls due.",
|
|
542
542
|
"dependencies": [
|
|
543
543
|
"@base-ui/react",
|
|
544
|
-
"dowel-ui@^0.
|
|
544
|
+
"dowel-ui@^0.37.0"
|
|
545
545
|
],
|
|
546
546
|
"registryDependencies": [],
|
|
547
547
|
"files": [
|
|
@@ -559,7 +559,7 @@
|
|
|
559
559
|
"title": "BigCalendar",
|
|
560
560
|
"description": "A month at a time, with what is on each day of it, filling the box it is given.",
|
|
561
561
|
"dependencies": [
|
|
562
|
-
"dowel-ui@^0.
|
|
562
|
+
"dowel-ui@^0.37.0"
|
|
563
563
|
],
|
|
564
564
|
"registryDependencies": [
|
|
565
565
|
"https://lacodda.github.io/dowel/r/big-calendar-item.json",
|
|
@@ -582,7 +582,7 @@
|
|
|
582
582
|
"description": "Where you are, as a trail rather than as a word. On a list screen the screen's own name is enough; on something opened from it, it is not - \"Works\" says nothing about which work, and the way back to the list is otherwise the browser's back button alone, which a desktop window does not visibly have.",
|
|
583
583
|
"dependencies": [
|
|
584
584
|
"@base-ui/react",
|
|
585
|
-
"dowel-ui@^0.
|
|
585
|
+
"dowel-ui@^0.37.0"
|
|
586
586
|
],
|
|
587
587
|
"registryDependencies": [],
|
|
588
588
|
"files": [
|
|
@@ -602,7 +602,7 @@
|
|
|
602
602
|
"dependencies": [
|
|
603
603
|
"@base-ui/react",
|
|
604
604
|
"class-variance-authority",
|
|
605
|
-
"dowel-ui@^0.
|
|
605
|
+
"dowel-ui@^0.37.0"
|
|
606
606
|
],
|
|
607
607
|
"registryDependencies": [],
|
|
608
608
|
"files": [
|
|
@@ -636,7 +636,7 @@
|
|
|
636
636
|
"title": "Calendar",
|
|
637
637
|
"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.",
|
|
638
638
|
"dependencies": [
|
|
639
|
-
"dowel-ui@^0.
|
|
639
|
+
"dowel-ui@^0.37.0"
|
|
640
640
|
],
|
|
641
641
|
"registryDependencies": [
|
|
642
642
|
"https://lacodda.github.io/dowel/r/calendar-math.json"
|
|
@@ -657,7 +657,7 @@
|
|
|
657
657
|
"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.",
|
|
658
658
|
"dependencies": [
|
|
659
659
|
"@base-ui/react",
|
|
660
|
-
"dowel-ui@^0.
|
|
660
|
+
"dowel-ui@^0.37.0"
|
|
661
661
|
],
|
|
662
662
|
"registryDependencies": [],
|
|
663
663
|
"files": [
|
|
@@ -677,7 +677,7 @@
|
|
|
677
677
|
"dependencies": [
|
|
678
678
|
"@base-ui/react",
|
|
679
679
|
"class-variance-authority",
|
|
680
|
-
"dowel-ui@^0.
|
|
680
|
+
"dowel-ui@^0.37.0"
|
|
681
681
|
],
|
|
682
682
|
"registryDependencies": [],
|
|
683
683
|
"files": [
|
|
@@ -696,7 +696,7 @@
|
|
|
696
696
|
"description": "The frame around a piece of code is the same everywhere and is written again in every product: the scroll that must not wrap, the gutter of line numbers that must not be selectable, the copy button, the caption saying which file this is, and the marking of the lines the reader was sent here to look at.",
|
|
697
697
|
"dependencies": [
|
|
698
698
|
"class-variance-authority",
|
|
699
|
-
"dowel-ui@^0.
|
|
699
|
+
"dowel-ui@^0.37.0"
|
|
700
700
|
],
|
|
701
701
|
"registryDependencies": [
|
|
702
702
|
"https://lacodda.github.io/dowel/r/copy-button.json"
|
|
@@ -717,7 +717,7 @@
|
|
|
717
717
|
"description": "One region of a screen that a reader shows or hides by choice - release notes under a version number, a filter panel, the raw payload under a log line. It differs from Accordion in the way a single light switch differs from a panel of them: there is one trigger and one panel, no group holding several and no rule about how many can be open, because there is only ever one to be open or not.",
|
|
718
718
|
"dependencies": [
|
|
719
719
|
"@base-ui/react",
|
|
720
|
-
"dowel-ui@^0.
|
|
720
|
+
"dowel-ui@^0.37.0"
|
|
721
721
|
],
|
|
722
722
|
"registryDependencies": [],
|
|
723
723
|
"files": [
|
|
@@ -735,7 +735,7 @@
|
|
|
735
735
|
"title": "ColorField",
|
|
736
736
|
"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.",
|
|
737
737
|
"dependencies": [
|
|
738
|
-
"dowel-ui@^0.
|
|
738
|
+
"dowel-ui@^0.37.0"
|
|
739
739
|
],
|
|
740
740
|
"registryDependencies": [
|
|
741
741
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -755,7 +755,7 @@
|
|
|
755
755
|
"title": "ColumnResizeHandle",
|
|
756
756
|
"description": "The handle, and the hook that keeps the widths it produces. Pointer events rather than HTML5 drag-and-drop: a desktop shell that takes file drops for itself never lets a `dragstart` reach the page, so the native API is a handle that does nothing there; pointer capture on the handle also keeps the drag alive when the pointer runs ahead of the cell, which at any speed above a crawl it does.",
|
|
757
757
|
"dependencies": [
|
|
758
|
-
"dowel-ui@^0.
|
|
758
|
+
"dowel-ui@^0.37.0"
|
|
759
759
|
],
|
|
760
760
|
"registryDependencies": [],
|
|
761
761
|
"files": [
|
|
@@ -775,7 +775,7 @@
|
|
|
775
775
|
"dependencies": [
|
|
776
776
|
"@base-ui/react",
|
|
777
777
|
"class-variance-authority",
|
|
778
|
-
"dowel-ui@^0.
|
|
778
|
+
"dowel-ui@^0.37.0"
|
|
779
779
|
],
|
|
780
780
|
"registryDependencies": [
|
|
781
781
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -799,7 +799,7 @@
|
|
|
799
799
|
"dependencies": [
|
|
800
800
|
"@base-ui/react",
|
|
801
801
|
"class-variance-authority",
|
|
802
|
-
"dowel-ui@^0.
|
|
802
|
+
"dowel-ui@^0.37.0"
|
|
803
803
|
],
|
|
804
804
|
"registryDependencies": [
|
|
805
805
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -841,7 +841,7 @@
|
|
|
841
841
|
"dependencies": [
|
|
842
842
|
"@base-ui/react",
|
|
843
843
|
"class-variance-authority",
|
|
844
|
-
"dowel-ui@^0.
|
|
844
|
+
"dowel-ui@^0.37.0"
|
|
845
845
|
],
|
|
846
846
|
"registryDependencies": [
|
|
847
847
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -863,7 +863,7 @@
|
|
|
863
863
|
"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>`.",
|
|
864
864
|
"dependencies": [
|
|
865
865
|
"@base-ui/react",
|
|
866
|
-
"dowel-ui@^0.
|
|
866
|
+
"dowel-ui@^0.37.0"
|
|
867
867
|
],
|
|
868
868
|
"registryDependencies": [
|
|
869
869
|
"https://lacodda.github.io/dowel/r/layer.json",
|
|
@@ -884,7 +884,7 @@
|
|
|
884
884
|
"title": "CopyButton",
|
|
885
885
|
"description": "Whatever a product shows in a panel - code, a payload, a log, one side of a comparison - somebody eventually wants to take it away, and the button that lets them is written again every time with the same three things missed.",
|
|
886
886
|
"dependencies": [
|
|
887
|
-
"dowel-ui@^0.
|
|
887
|
+
"dowel-ui@^0.37.0"
|
|
888
888
|
],
|
|
889
889
|
"registryDependencies": [],
|
|
890
890
|
"files": [
|
|
@@ -902,7 +902,7 @@
|
|
|
902
902
|
"title": "Copyable",
|
|
903
903
|
"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.",
|
|
904
904
|
"dependencies": [
|
|
905
|
-
"dowel-ui@^0.
|
|
905
|
+
"dowel-ui@^0.37.0"
|
|
906
906
|
],
|
|
907
907
|
"registryDependencies": [],
|
|
908
908
|
"files": [
|
|
@@ -920,7 +920,7 @@
|
|
|
920
920
|
"title": "DatePicker",
|
|
921
921
|
"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.",
|
|
922
922
|
"dependencies": [
|
|
923
|
-
"dowel-ui@^0.
|
|
923
|
+
"dowel-ui@^0.37.0"
|
|
924
924
|
],
|
|
925
925
|
"registryDependencies": [
|
|
926
926
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -933,7 +933,7 @@
|
|
|
933
933
|
"path": "ui/date-picker.tsx",
|
|
934
934
|
"target": "@ui/date-picker.tsx",
|
|
935
935
|
"type": "registry:ui",
|
|
936
|
-
"content": "import { useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The\n * application's language unless stated - see `useLocale`. */\n locale?: string\n disabled?: boolean\n name?: string\n 'aria-label'?: string\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, language])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-
|
|
936
|
+
"content": "import { useId, useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DatePicker - a field that opens a month.\n *\n * The trigger is a button rather than a text input, and that is the decision\n * worth stating. A typable date field has to answer \"what does `03/04/26`\n * mean\" in a locale it cannot be sure of, and it answers wrong for half the\n * world; a button showing the date spelled out has no such question. Where\n * typing genuinely matters - a birth date, forty years back - the calendar is\n * the wrong control anyway and a product should reach for a plain field.\n *\n * The value is a calendar date as a string, `YYYY-MM-DD`, for the reasons the\n * Calendar states: a date with a timezone is a moment, and moments cross\n * midnight when they are serialised.\n *\n * What is shown is `Intl`'s own long form - \"2 September 2026\" here, \"September\n * 2, 2026\" in the United States - because a date written the reader's way is\n * one they do not have to decode.\n *\n * Inside a `Field` the label names the trigger: the id and the two `aria-`\n * references Base UI hands a control are put on the button. Without them the\n * label's `for` pointed at nothing and the button was named by whatever date\n * it showed - kilna's own picker had grown the same three props for the same\n * reason.\n */\n\nexport interface DatePickerProps {\n /** The chosen day, or `undefined` for none. */\n value?: IsoDate\n onValueChange?: (value: IsoDate) => void\n /** Bounds, inclusive. */\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. The product's word, since\n * a default here would be English inside a primitive. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n /** How the date is written and which day starts the week. The\n * application's language unless stated - see `useLocale`. */\n locale?: string\n disabled?: boolean\n name?: string\n /** Put on the trigger, so a `Field` label can point at it. */\n id?: string\n 'aria-label'?: string\n 'aria-labelledby'?: string\n 'aria-describedby'?: string\n 'aria-invalid'?: boolean | 'true' | 'false'\n className?: string\n}\n\nexport function DatePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n name,\n className,\n 'aria-label': ariaLabel,\n id,\n 'aria-labelledby': labelledBy,\n 'aria-describedby': describedBy,\n 'aria-invalid': invalid,\n}: DatePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n // Named by its label and then by the date it shows. A label alone would be\n // announced without the value on the button, and a name that does not hold\n // the words on the button fails WCAG 2.5.3: someone saying what they see to\n // voice control says words the name does not have. With an `aria-label` the\n // trigger names itself - an element listed in its own `aria-labelledby` is\n // read by its `aria-label`.\n const ownId = useId()\n const triggerId = id ?? ownId\n const shownId = useId()\n const naming = labelledBy ?? (ariaLabel === undefined ? undefined : triggerId)\n\n const shown = useMemo(() => {\n if (value === undefined || !isIsoDate(value)) return undefined\n const [year, month, day] = value.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'long' }).format(\n new Date(year, month - 1, day),\n )\n }, [value, language])\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n id={triggerId}\n aria-labelledby={naming === undefined ? undefined : `${naming} ${shownId}`}\n aria-describedby={describedBy}\n aria-invalid={invalid}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n {/* The empty face in `dim`: `faint` at this size measured 3.2:1. */}\n <span id={shownId} className={cn('truncate', shown === undefined && 'text-dim')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n {/* The value also goes into a form, because a button is not a field and\n * a form submitting the screen would otherwise lose the date. */}\n {name !== undefined && <input type=\"hidden\" name={name} value={value ?? ''} />}\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={value}\n min={min}\n max={max}\n locale={language}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={(next) => {\n onValueChange?.(next)\n // Choosing a day is the whole errand: the popup closes rather\n // than waiting for a second dismissing click.\n setOpen(false)\n }}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
|
|
937
937
|
}
|
|
938
938
|
]
|
|
939
939
|
},
|
|
@@ -943,7 +943,7 @@
|
|
|
943
943
|
"title": "DateRangePicker",
|
|
944
944
|
"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.",
|
|
945
945
|
"dependencies": [
|
|
946
|
-
"dowel-ui@^0.
|
|
946
|
+
"dowel-ui@^0.37.0"
|
|
947
947
|
],
|
|
948
948
|
"registryDependencies": [
|
|
949
949
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -956,7 +956,7 @@
|
|
|
956
956
|
"path": "ui/date-range-picker.tsx",
|
|
957
957
|
"target": "@ui/date-range-picker.tsx",
|
|
958
958
|
"type": "registry:ui",
|
|
959
|
-
"content": "import { useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n 'aria-label'?: string\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, language])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n <span className={cn('truncate', shown === undefined && 'text-
|
|
959
|
+
"content": "import { useId, useMemo, useState } from 'react'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Popover, PopoverPopup, PopoverTrigger } from './popover'\nimport { Calendar } from './calendar'\nimport { isIsoDate, type IsoDate } from './calendar-math'\n\n/*\n * DateRangePicker - two days, chosen in two clicks.\n *\n * The interesting part is the state between them. After the first click there\n * is a start and no end, and that is not an incomplete range to be hidden or\n * a range of one day - it is the normal middle of the interaction, and the\n * calendar has to show it: the first day marked, the days under the pointer\n * shading as the reader moves, the popup staying open. Products that skip it\n * end up with a picker that seems to do nothing until the second click.\n *\n * So the value is a pair where either end may be absent, and the component is\n * explicit about which half it is waiting for. `onValueChange` fires on both\n * clicks - a product watching it sees the half-made range, which is what lets\n * it show \"from 2 September\" while the reader is still deciding.\n *\n * The second click can land before the first. Clicking the 20th and then the\n * 10th means the 10th to the 20th, because that is plainly what was meant;\n * refusing it would be correct and unhelpful.\n */\n\nexport interface DateRange {\n /** The first day, inclusive. */\n start?: IsoDate\n /** The last day, inclusive. Absent while the range is half made. */\n end?: IsoDate\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange\n onValueChange?: (value: DateRange) => void\n min?: IsoDate\n max?: IsoDate\n /** What the trigger says when nothing is chosen. */\n placeholder: string\n /** Names the two month-paging buttons inside the calendar. */\n previousMonthLabel: string\n nextMonthLabel: string\n locale?: string\n disabled?: boolean\n /** Put on the trigger, so a `Field` label can point at it. */\n id?: string\n 'aria-label'?: string\n 'aria-labelledby'?: string\n 'aria-describedby'?: string\n 'aria-invalid'?: boolean | 'true' | 'false'\n className?: string\n}\n\nexport function DateRangePicker({\n value,\n onValueChange,\n min,\n max,\n placeholder,\n previousMonthLabel,\n nextMonthLabel,\n locale,\n disabled = false,\n className,\n 'aria-label': ariaLabel,\n id,\n 'aria-labelledby': labelledBy,\n 'aria-describedby': describedBy,\n 'aria-invalid': invalid,\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false)\n const language = useLocale(locale)\n // Named by its label and then by the date it shows. A label alone would be\n // announced without the value on the button, and a name that does not hold\n // the words on the button fails WCAG 2.5.3: someone saying what they see to\n // voice control says words the name does not have. With an `aria-label` the\n // trigger names itself - an element listed in its own `aria-labelledby` is\n // read by its `aria-label`.\n const ownId = useId()\n const triggerId = id ?? ownId\n const shownId = useId()\n const naming = labelledBy ?? (ariaLabel === undefined ? undefined : triggerId)\n\n const range = value ?? {}\n const waitingForEnd = range.start !== undefined && range.end === undefined\n\n const shown = useMemo(() => {\n const write = (date: IsoDate) => {\n const [year, month, day] = date.split('-').map(Number) as [number, number, number]\n return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(\n new Date(year, month - 1, day),\n )\n }\n if (range.start === undefined || !isIsoDate(range.start)) return undefined\n if (range.end === undefined) return write(range.start)\n // An en dash rather than a hyphen: this is a span, and the two read\n // differently at a glance in a row of dates.\n return `${write(range.start)} – ${write(range.end)}`\n }, [range.start, range.end, language])\n\n const choose = (date: IsoDate) => {\n // A fresh click starts a new range whenever there is nothing waiting -\n // including right after a completed one, which is what a reader means by\n // clicking again.\n if (!waitingForEnd) {\n onValueChange?.({ start: date })\n return\n }\n\n const start = range.start!\n // Backwards is fine: the reader plainly meant the span between them.\n const next: DateRange = date < start ? { start: date, end: start } : { start, end: date }\n onValueChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger\n disabled={disabled}\n aria-label={ariaLabel}\n id={triggerId}\n aria-labelledby={naming === undefined ? undefined : `${naming} ${shownId}`}\n aria-describedby={describedBy}\n aria-invalid={invalid}\n className={cn(\n fieldClasses,\n 'flex h-control cursor-pointer items-center gap-2 text-left',\n 'disabled:cursor-not-allowed',\n className,\n )}\n >\n <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-faint\" fill=\"none\" aria-hidden>\n <rect x=\"2\" y=\"3\" width=\"12\" height=\"11\" rx=\"2\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n <path d=\"M2 6.5h12M5.5 2v2M10.5 2v2\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n </svg>\n {/* The empty face in `dim`: `faint` at this size measured 3.2:1. */}\n <span id={shownId} className={cn('truncate', shown === undefined && 'text-dim')}>\n {shown ?? placeholder}\n </span>\n </PopoverTrigger>\n\n <PopoverPopup arrow={false} className=\"w-auto p-3\">\n <Calendar\n value={range.start}\n rangeEnd={range.end}\n min={min}\n max={max}\n locale={language}\n aria-label={ariaLabel ?? placeholder}\n previousMonthLabel={previousMonthLabel}\n nextMonthLabel={nextMonthLabel}\n onValueChange={choose}\n />\n </PopoverPopup>\n </Popover>\n )\n}\n"
|
|
960
960
|
}
|
|
961
961
|
]
|
|
962
962
|
},
|
|
@@ -968,7 +968,7 @@
|
|
|
968
968
|
"dependencies": [
|
|
969
969
|
"@base-ui/react",
|
|
970
970
|
"class-variance-authority",
|
|
971
|
-
"dowel-ui@^0.
|
|
971
|
+
"dowel-ui@^0.37.0"
|
|
972
972
|
],
|
|
973
973
|
"registryDependencies": [
|
|
974
974
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1005,7 +1005,7 @@
|
|
|
1005
1005
|
"description": "The question this answers is \"how did this read before, and how does it read now\" - a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is no staging, no comment, nothing to accept. It is for looking.",
|
|
1006
1006
|
"dependencies": [
|
|
1007
1007
|
"class-variance-authority",
|
|
1008
|
-
"dowel-ui@^0.
|
|
1008
|
+
"dowel-ui@^0.37.0"
|
|
1009
1009
|
],
|
|
1010
1010
|
"registryDependencies": [
|
|
1011
1011
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -1027,7 +1027,7 @@
|
|
|
1027
1027
|
"description": "The line between one part of a screen and the next. The set already had four of them - in the menu, the select, the context menu and the action bar - and each was written inside the thing it divided, so a screen that wanted a rule between two sections had nothing and reached for a bare `<hr>` or a `div` with a background.",
|
|
1028
1028
|
"dependencies": [
|
|
1029
1029
|
"class-variance-authority",
|
|
1030
|
-
"dowel-ui@^0.
|
|
1030
|
+
"dowel-ui@^0.37.0"
|
|
1031
1031
|
],
|
|
1032
1032
|
"registryDependencies": [],
|
|
1033
1033
|
"files": [
|
|
@@ -1047,7 +1047,7 @@
|
|
|
1047
1047
|
"dependencies": [
|
|
1048
1048
|
"@base-ui/react",
|
|
1049
1049
|
"class-variance-authority",
|
|
1050
|
-
"dowel-ui@^0.
|
|
1050
|
+
"dowel-ui@^0.37.0"
|
|
1051
1051
|
],
|
|
1052
1052
|
"registryDependencies": [
|
|
1053
1053
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -1068,7 +1068,7 @@
|
|
|
1068
1068
|
"title": "DurationField",
|
|
1069
1069
|
"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.",
|
|
1070
1070
|
"dependencies": [
|
|
1071
|
-
"dowel-ui@^0.
|
|
1071
|
+
"dowel-ui@^0.37.0"
|
|
1072
1072
|
],
|
|
1073
1073
|
"registryDependencies": [
|
|
1074
1074
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1089,7 +1089,7 @@
|
|
|
1089
1089
|
"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.",
|
|
1090
1090
|
"dependencies": [
|
|
1091
1091
|
"class-variance-authority",
|
|
1092
|
-
"dowel-ui@^0.
|
|
1092
|
+
"dowel-ui@^0.37.0"
|
|
1093
1093
|
],
|
|
1094
1094
|
"registryDependencies": [],
|
|
1095
1095
|
"files": [
|
|
@@ -1143,7 +1143,7 @@
|
|
|
1143
1143
|
"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.",
|
|
1144
1144
|
"dependencies": [
|
|
1145
1145
|
"@base-ui/react",
|
|
1146
|
-
"dowel-ui@^0.
|
|
1146
|
+
"dowel-ui@^0.37.0"
|
|
1147
1147
|
],
|
|
1148
1148
|
"registryDependencies": [],
|
|
1149
1149
|
"files": [
|
|
@@ -1151,7 +1151,7 @@
|
|
|
1151
1151
|
"path": "ui/field.tsx",
|
|
1152
1152
|
"target": "@ui/field.tsx",
|
|
1153
1153
|
"type": "registry:ui",
|
|
1154
|
-
"content": "import { useId, type ReactElement, type ReactNode } from 'react'\nimport { Field as Base } from '@base-ui/react/field'\nimport { Fieldset } from '@base-ui/react/fieldset'\nimport { cn } from 'dowel-ui'\n\n/*\n * Field - the label, the control, and what the form has to say about it.\n *\n * Every form is the same four parts repeated: a name for the control, the\n * control, sometimes a hint, and sometimes an error. Written by hand each\n * time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no\n * screen reader mentions, the error appears in red and is announced by\n * nothing at all. This is that arrangement, once.\n *\n * The wiring is the point, and Base UI does it: the label points at the\n * control, the description and the error are named by `aria-describedby`, and\n * the control is marked invalid while an error is showing. None of that is\n * visible when it works, which is exactly why a product stops doing it.\n *\n * `error` is a string this is given, not a rule this enforces. dowel has no\n * opinion about where the string came from - Base UI's own `validate`, a\n * schema, react-hook-form, or a server that just said no - because a design\n * system that picked a form library would be choosing for products that\n * already chose. The one thing it insists on is that an error, once it\n * exists, is announced and points at the field it belongs to.\n *\n * The label is always rendered. A field whose name is only a placeholder\n * loses that name the moment someone types, and a placeholder is not a label\n * to anything that reads the page aloud; `labelHidden` takes it off the\n * screen and leaves it in the accessibility tree.\n */\n\nexport interface FieldProps {\n /** What the control is called. Always rendered; `labelHidden` only takes it\n * off the screen. */\n label: ReactNode\n /** The control itself - Input, Textarea, Select, Checkbox, anything.\n *\n * A single element rather than arbitrary nodes, because it is handed to\n * Base UI to carry the field's id and its `aria-*` wiring. Two children, or\n * a bare string, would leave the label naming nothing. */\n children: ReactElement\n /** A hint under the control. Hidden while an error is showing: two lines of\n * small print under one field is one line too many, and the error is the\n * one that matters. */\n help?: ReactNode\n /** What is wrong, if anything. Its presence is what marks the control\n * invalid - there is no separate `invalid` prop to keep in step. */\n error?: ReactNode\n /** Keep the label for screen readers but take it off the screen. For a\n * field whose meaning is obvious in context - a search box in a toolbar. */\n labelHidden?: boolean\n className?: string\n /** Marks the field required, which is a statement about the form rather\n * than about validation: the label gets the mark a reader looks for. */\n required?: boolean\n /** The control's `name`, forwarded so a `Form` can attach a server error to\n * this field by name. */\n name?: string\n disabled?: boolean\n}\n\nexport function Field({\n label,\n children,\n help,\n error,\n labelHidden = false,\n className,\n required = false,\n name,\n disabled = false,\n}: FieldProps) {\n const invalid = error !== undefined && error !== null && error !== false\n\n return (\n <Base.Root\n name={name}\n disabled={disabled}\n // Base UI marks the control invalid from its own validity state; this\n // says so for an error that arrived from anywhere else.\n invalid={invalid || undefined}\n className={cn('flex flex-col gap-1.5', className)}\n >\n <Base.Label\n className={cn(\n 'caption',\n
|
|
1154
|
+
"content": "import { useId, type ReactElement, type ReactNode } from 'react'\nimport { Field as Base } from '@base-ui/react/field'\nimport { Fieldset } from '@base-ui/react/fieldset'\nimport { cn } from 'dowel-ui'\n\n/*\n * Field - the label, the control, and what the form has to say about it.\n *\n * Every form is the same four parts repeated: a name for the control, the\n * control, sometimes a hint, and sometimes an error. Written by hand each\n * time, they drift - the label loses its `htmlFor`, the hint is a `<div>` no\n * screen reader mentions, the error appears in red and is announced by\n * nothing at all. This is that arrangement, once.\n *\n * The wiring is the point, and Base UI does it: the label points at the\n * control, the description and the error are named by `aria-describedby`, and\n * the control is marked invalid while an error is showing. None of that is\n * visible when it works, which is exactly why a product stops doing it.\n *\n * `error` is a string this is given, not a rule this enforces. dowel has no\n * opinion about where the string came from - Base UI's own `validate`, a\n * schema, react-hook-form, or a server that just said no - because a design\n * system that picked a form library would be choosing for products that\n * already chose. The one thing it insists on is that an error, once it\n * exists, is announced and points at the field it belongs to.\n *\n * The label is always rendered. A field whose name is only a placeholder\n * loses that name the moment someone types, and a placeholder is not a label\n * to anything that reads the page aloud; `labelHidden` takes it off the\n * screen and leaves it in the accessibility tree.\n *\n * A Switch or a Checkbox is drawn `beside` its name rather than under it,\n * inside the label, as on every settings screen there is: a press on the words\n * flips it. The other controls of the set - a box, a Select, a NumberField, a\n * DatePicker - sit under the caption, and the ones built on Base UI find the\n * field they sit in and take its wiring themselves.\n */\n\nexport interface FieldProps {\n /** What the control is called. Always rendered; `labelHidden` only takes it\n * off the screen. */\n label: ReactNode\n /** The control itself - Input, Textarea, Select, Checkbox, anything.\n *\n * A single element rather than arbitrary nodes, because it is handed to\n * Base UI to carry the field's id and its `aria-*` wiring. Two children, or\n * a bare string, would leave the label naming nothing. */\n children: ReactElement\n /** A hint under the control. Hidden while an error is showing: two lines of\n * small print under one field is one line too many, and the error is the\n * one that matters. */\n help?: ReactNode\n /** What is wrong, if anything. Its presence is what marks the control\n * invalid - there is no separate `invalid` prop to keep in step. */\n error?: ReactNode\n /** Keep the label for screen readers but take it off the screen. For a\n * field whose meaning is obvious in context - a search box in a toolbar. */\n labelHidden?: boolean\n className?: string\n /** Marks the field required, which is a statement about the form rather\n * than about validation: the label gets the mark a reader looks for. */\n required?: boolean\n /** The control's `name`, forwarded so a `Form` can attach a server error to\n * this field by name. */\n name?: string\n disabled?: boolean\n /** The control beside its name, inside the label, rather than under a\n * caption: for a Switch or a Checkbox. */\n beside?: boolean\n}\n\nexport function Field({\n label,\n children,\n help,\n error,\n labelHidden = false,\n className,\n required = false,\n name,\n disabled = false,\n beside = false,\n}: FieldProps) {\n const invalid = error !== undefined && error !== null && error !== false\n const mark = required && (\n <span aria-hidden className=\"ml-0.5 text-bad\">\n *\n </span>\n )\n\n return (\n <Base.Root\n name={name}\n disabled={disabled}\n // Base UI marks the control invalid from its own validity state; this\n // says so for an error that arrived from anywhere else.\n invalid={invalid || undefined}\n className={cn('flex flex-col gap-1.5', className)}\n >\n {beside ? (\n // The control inside its label: Base UI names it by the label's id,\n // and the whole row is what a pointer aims at.\n <Base.Label\n className={cn(\n 'flex w-fit cursor-pointer items-center gap-2.5 text-sm text-text',\n 'has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50',\n )}\n >\n {children}\n <span className={cn(labelHidden && 'sr-only')}>\n {label}\n {mark}\n </span>\n </Base.Label>\n ) : (\n <Base.Label\n className={cn(\n 'caption',\n // Off the screen, still in the accessibility tree. Not\n // `display: none`, which would take it out of both.\n labelHidden && 'sr-only',\n )}\n >\n {label}\n {mark}\n </Base.Label>\n )}\n\n {/* The control is handed to Base UI rather than merely nested inside\n * it. This is the whole component: a plain child renders a label whose\n * `for` points at an id nothing carries, so the label looks wired and\n * names nothing - which is exactly the bug this exists to prevent, and\n * it is invisible in a screenshot. `render` gives the child the id,\n * `aria-labelledby` and `aria-describedby` instead. */}\n {beside ? null : <Base.Control render={children} />}\n\n {/* The error wins the one line under the control. `match` is not used:\n * the string is already the decision - this renders whatever it was\n * handed rather than asking the browser what is wrong. */}\n {invalid ? (\n <Base.Error className=\"text-xs text-bad\" match>\n {error}\n </Base.Error>\n ) : (\n help !== undefined && <Base.Description className=\"text-xs text-dim\">{help}</Base.Description>\n )}\n </Base.Root>\n )\n}\n\nexport interface FieldGroupProps {\n /** What the group is called - \"Type\", \"References\". Always rendered;\n * `labelHidden` only takes it off the screen. */\n label: ReactNode\n /** The controls: a row of chips, a segment, a grid of pictures, several\n * checkboxes. Anything - a group names its contents rather than handing\n * them an id. */\n children: ReactNode\n /** A hint under the group, read as the group's description. Hidden while\n * an error is showing, as in Field. */\n help?: ReactNode\n /** What is wrong with the group as a whole - \"choose at least one\". */\n error?: ReactNode\n labelHidden?: boolean\n required?: boolean\n /** Disables every native control inside, which is what a `<fieldset>`\n * does on its own. */\n disabled?: boolean\n className?: string\n}\n\n/**\n * FieldGroup - one name for several controls.\n *\n * Field labels exactly one control, and it has to: the label points at an id\n * the control carries. A row of chips, a segment or a grid of pictures has no\n * single control to point at, and the obvious workaround - a `<label>`\n * wrapped around the row - is a trap. A label with no `for` labels the first\n * labelable element inside it, and a click on any plain part of it is\n * forwarded there. kilna measured what that does in its style dialog: a click\n * on the caption \"Type\" silently set the first type, and a click on\n * \"References\" pressed the hidden remove cross of the first picture, which\n * deleted it for good.\n *\n * So a group is a `<fieldset>` with a legend, through Base UI's Fieldset: the\n * group is announced by its name as a reader enters it, and a caption has\n * nothing a click could be forwarded to. The caption is the same `caption` a\n * Field's label is, so the two kinds of field sit in one form as one kind.\n */\nexport function FieldGroup({\n label,\n children,\n help,\n error,\n labelHidden = false,\n required = false,\n disabled = false,\n className,\n}: FieldGroupProps) {\n const noteId = useId()\n const invalid = error !== undefined && error !== null && error !== false\n const note = invalid ? error : help\n\n return (\n <Fieldset.Root\n disabled={disabled}\n aria-describedby={note === undefined ? undefined : noteId}\n data-invalid={invalid ? '' : undefined}\n // `min-w-0`: a fieldset's intrinsic minimum is its content, so a row of\n // chips inside a grid cell refuses to wrap and pushes the grid apart.\n className={cn('m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0', className)}\n >\n <Fieldset.Legend className={cn('caption', labelHidden && 'sr-only')}>\n {label}\n {required && (\n <span aria-hidden className=\"ml-0.5 text-bad\">\n *\n </span>\n )}\n </Fieldset.Legend>\n\n {children}\n\n {note === undefined ? null : (\n <span id={noteId} className={cn('text-xs', invalid ? 'text-bad' : 'text-dim')}>\n {note}\n </span>\n )}\n </Fieldset.Root>\n )\n}\n"
|
|
1155
1155
|
}
|
|
1156
1156
|
]
|
|
1157
1157
|
},
|
|
@@ -1161,7 +1161,7 @@
|
|
|
1161
1161
|
"title": "FileDrop",
|
|
1162
1162
|
"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.",
|
|
1163
1163
|
"dependencies": [
|
|
1164
|
-
"dowel-ui@^0.
|
|
1164
|
+
"dowel-ui@^0.37.0"
|
|
1165
1165
|
],
|
|
1166
1166
|
"registryDependencies": [],
|
|
1167
1167
|
"files": [
|
|
@@ -1179,7 +1179,7 @@
|
|
|
1179
1179
|
"title": "FilterPopover",
|
|
1180
1180
|
"description": "A text box, a handful of checkboxes - with one way to clear it. The shell only: what goes in the panel is the caller's, since a stage is ticked and a title is typed and the popover has no opinion.",
|
|
1181
1181
|
"dependencies": [
|
|
1182
|
-
"dowel-ui@^0.
|
|
1182
|
+
"dowel-ui@^0.37.0"
|
|
1183
1183
|
],
|
|
1184
1184
|
"registryDependencies": [
|
|
1185
1185
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1200,7 +1200,7 @@
|
|
|
1200
1200
|
"title": "Frame",
|
|
1201
1201
|
"description": "The geometry of a screen whose bottom edge is the window's: a column that hands its height down and scrolls nowhere itself, a panel whose bars stand while its body scrolls between them, and a list beside what is open in it.",
|
|
1202
1202
|
"dependencies": [
|
|
1203
|
-
"dowel-ui@^0.
|
|
1203
|
+
"dowel-ui@^0.37.0"
|
|
1204
1204
|
],
|
|
1205
1205
|
"registryDependencies": [
|
|
1206
1206
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1222,7 +1222,7 @@
|
|
|
1222
1222
|
"description": "The layout jump a loading picture leaves behind is not a styling defect, it is a missing number: the browser has nowhere to put the box until the file arrives and tells it the pixel size, so everything below the picture slides down and back up. `AspectRatio` is that number, given up front by the caller rather than discovered by the browser - a plain box on CSS `aspect-ratio` that reserves its space the instant it is in the tree, and lets any child (an `<img>`, a map, a video) fill it with `size-full`.",
|
|
1223
1223
|
"dependencies": [
|
|
1224
1224
|
"class-variance-authority",
|
|
1225
|
-
"dowel-ui@^0.
|
|
1225
|
+
"dowel-ui@^0.37.0"
|
|
1226
1226
|
],
|
|
1227
1227
|
"registryDependencies": [],
|
|
1228
1228
|
"files": [
|
|
@@ -1230,7 +1230,7 @@
|
|
|
1230
1230
|
"path": "ui/image.tsx",
|
|
1231
1231
|
"target": "@ui/image.tsx",
|
|
1232
1232
|
"type": "registry:ui",
|
|
1233
|
-
"content": "import {\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ImgHTMLAttributes,\n type ReactNode,\n} from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * AspectRatio and Image.\n *\n * The layout jump a loading picture leaves behind is not a styling defect,\n * it is a missing number: the browser has nowhere to put the box until the\n * file arrives and tells it the pixel size, so everything below the picture\n * slides down and back up. `AspectRatio` is that number, given up front by\n * the caller rather than discovered by the browser - a plain box on CSS\n * `aspect-ratio` that reserves its space the instant it is in the tree, and\n * lets any child (an `<img>`, a map, a video) fill it with `size-full`.\n *\n * `Image` is what goes inside one. It carries its own three-state life, the\n * same shape `Avatar` already uses for its picture - loading, loaded, and\n * failed - because a face and a photograph fail to load for the same\n * reasons and should not fail differently:\n *\n * - **Loading** shows a placeholder in the exact box the ratio already\n * reserved, built from `Skeleton`'s own look (`animate-pulse bg-soft`)\n * rather than a new grey rectangle invented for this one component - one\n * loading language for the set, not two that drift apart over time.\n * - **Loaded** fades the picture in over `--duration-base` rather than\n * popping it in, because a sudden picture reads as a flash the eye has to\n * catch; the theme's own reduced-motion media query cuts every duration to\n * near zero for a reader who asked for that, so nothing extra is wired up\n * here for it. The `<img>` itself is always in the DOM - hidden with\n * opacity and `aria-hidden`, not unmounted - so the load event has\n * something to fire on the first render rather than never.\n * - **Failed** shows `fallback` instead of the browser's own broken-image\n * glyph, which looks like the product forgot the picture rather than that\n * the network did.\n *\n * The state a naive version gets wrong is the fourth one: a picture already\n * in the browser's cache is already `complete` the instant the `<img>` mounts,\n * and no `load` event follows - there is nothing left to load. Without the\n * check in the effect below, that picture's placeholder would sit forever,\n * because the one event this component was waiting for already happened\n * before it started listening. The same effect is what makes navigating back\n * to an already-seen picture look instant instead of re-showing a skeleton\n * for a picture that is sitting in memory.\n *\n * Base UI has no image-loading primitive of its own to reach for here - its\n * `Avatar` component is a separate package this workspace does not install,\n * and this line's own `Avatar` already reimplements the same loaded/failed\n * state by hand for the same reason: a picture is common enough, and small\n * enough, that a dependency would cost more than the `useState` it replaces.\n * `Image` follows that precedent rather than inventing a second one.\n */\n\nexport const aspectRatioVariants = cva('relative w-full overflow-hidden bg-soft', {\n variants: {\n /** How the box is shaped. `square` and `video` are named because those\n * are the two ratios a product reaches for by name; anything else is a\n * plain number on `ratio`. */\n ratio: {\n square: 'aspect-square',\n video: 'aspect-video',\n },\n },\n})\n\n/* `ratio` carries both the named steps and a raw number, which `cva`'s own\n * variant type cannot express - its keys are strings, not numbers. The prop\n * is widened here and split apart in the component instead. */\ntype AspectRatioVariant = VariantProps<typeof aspectRatioVariants>['ratio'] | number\n\nexport interface AspectRatioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The box's shape: `square`, `video`, or a raw number as width / height -\n * `4 / 3`, `21 / 9` - for anything the named steps do not cover. */\n ratio?: AspectRatioVariant\n children?: ReactNode\n}\n\n/** A box that keeps its shape before, during and after whatever fills it\n * loads. `size-full` on the child is the contract: this component reserves\n * the rectangle, the child covers it. */\nexport function AspectRatio({ ratio, className, children, style, ...props }: AspectRatioProps) {\n const named = typeof ratio === 'number' ? undefined : ratio\n const numeric = typeof ratio === 'number' ? ratio : undefined\n\n return (\n <div\n className={cn(aspectRatioVariants({ ratio: named }), className)}\n style={numeric ? { aspectRatio: numeric, ...style } : style}\n {...props}\n >\n {children}\n </div>\n )\n}\n\nexport const imageVariants = cva('size-full', {\n variants: {\n /** How the picture fills a box that is not its own shape. `cover` for a\n * photo standing in for the whole box; `contain` for a logo or a\n * diagram where cropping would cut off the point of it. */\n fit: {\n cover: 'object-cover',\n contain: 'object-contain',\n },\n },\n defaultVariants: { fit: 'cover' },\n})\n\nexport interface ImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'onLoad' | 'onError' | 'placeholder'>,\n VariantProps<typeof imageVariants> {\n /** Required, with no default: a decorative picture says so explicitly with\n * `alt=\"\"` rather than by omission, which is indistinguishable from a\n * picture nobody described yet. */\n alt: string\n /** What stands in the box when the picture fails to load - the product's\n * text or icon, not a default this component would have to write in some\n * one language. */\n fallback?: ReactNode\n}\n\n/** A picture that reserves its box, shows `Skeleton`'s placeholder while it\n * loads, fades in once it has, and shows `fallback` instead of a broken-image\n * glyph if it can't be loaded at all. */\nexport function Image({ fallback, fit, className, src, alt, ...props }: ImageProps) {\n const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading')\n const ref = useRef<HTMLImageElement>(null)\n\n // A picture already in the browser's cache is already `complete` the\n // instant this mounts, and fires no `load` event of its own - there is\n // nothing left to load. Without this check the placeholder above it would\n // never be told to leave. `useLayoutEffect` rather than `useEffect`, so a\n // cached picture never paints its placeholder for even one frame - only\n // the layout-timed effect runs before the browser has painted.\n useLayoutEffect(() => {\n const img = ref.current\n setStatus(img && img.complete && img.naturalWidth > 0 ? 'loaded' : 'loading')\n }, [src])\n\n return (\n <span className=\"relative block size-full overflow-hidden\">\n {status !== 'failed' && (\n <img\n {...props}\n ref={ref}\n src={src}\n alt={alt}\n onLoad={() => setStatus('loaded')}\n onError={() => setStatus('failed')}\n className={cn(\n imageVariants({ fit }),\n 'absolute inset-0 transition-opacity duration-base ease-out',\n status === 'loaded' ? 'opacity-100' : 'opacity-0',\n className,\n )}\n // Hidden from a reader until there is a picture to announce - an\n // `alt` describing content that is not there yet, or never\n // arrives, is worse than saying nothing.\n aria-hidden={status !== 'loaded'}\n />\n )}\n {status === 'loading' && (\n <span aria-hidden className=\"absolute inset-0 animate-pulse rounded-md bg-soft\" />\n )}\n {status === 'failed' && fallback && (\n <span className=\"absolute inset-0 flex items-center justify-center text-dim\">{fallback}</span>\n )}\n </span>\n )\n}\n"
|
|
1233
|
+
"content": "import {\n useLayoutEffect,\n useRef,\n useState,\n type HTMLAttributes,\n type ImgHTMLAttributes,\n type ReactNode,\n} from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * AspectRatio and Image.\n *\n * The layout jump a loading picture leaves behind is not a styling defect,\n * it is a missing number: the browser has nowhere to put the box until the\n * file arrives and tells it the pixel size, so everything below the picture\n * slides down and back up. `AspectRatio` is that number, given up front by\n * the caller rather than discovered by the browser - a plain box on CSS\n * `aspect-ratio` that reserves its space the instant it is in the tree, and\n * lets any child (an `<img>`, a map, a video) fill it with `size-full`.\n *\n * `Image` is what goes inside one. It carries its own three-state life, the\n * same shape `Avatar` already uses for its picture - loading, loaded, and\n * failed - because a face and a photograph fail to load for the same\n * reasons and should not fail differently:\n *\n * - **Loading** shows a placeholder in the exact box the ratio already\n * reserved, built from `Skeleton`'s own look (`animate-pulse bg-soft`)\n * rather than a new grey rectangle invented for this one component - one\n * loading language for the set, not two that drift apart over time.\n * - **Loaded** fades the picture in over `--duration-base` rather than\n * popping it in, because a sudden picture reads as a flash the eye has to\n * catch; the theme's own reduced-motion media query cuts every duration to\n * near zero for a reader who asked for that, so nothing extra is wired up\n * here for it. The `<img>` itself is always in the DOM - hidden with\n * opacity and `aria-hidden`, not unmounted - so the load event has\n * something to fire on the first render rather than never.\n * - **Failed** shows `fallback` instead of the browser's own broken-image\n * glyph, which looks like the product forgot the picture rather than that\n * the network did.\n *\n * The state a naive version gets wrong is the fourth one: a picture already\n * in the browser's cache is already `complete` the instant the `<img>` mounts,\n * and no `load` event follows - there is nothing left to load. Without the\n * check in the effect below, that picture's placeholder would sit forever,\n * because the one event this component was waiting for already happened\n * before it started listening. The same effect is what makes navigating back\n * to an already-seen picture look instant instead of re-showing a skeleton\n * for a picture that is sitting in memory.\n *\n * Base UI has no image-loading primitive of its own to reach for here - its\n * `Avatar` component is a separate package this workspace does not install,\n * and this line's own `Avatar` already reimplements the same loaded/failed\n * state by hand for the same reason: a picture is common enough, and small\n * enough, that a dependency would cost more than the `useState` it replaces.\n * `Image` follows that precedent rather than inventing a second one.\n */\n\nexport const aspectRatioVariants = cva('relative w-full overflow-hidden bg-soft', {\n variants: {\n /** How the box is shaped. `square` and `video` are named because those\n * are the two ratios a product reaches for by name; anything else is a\n * plain number on `ratio`. */\n ratio: {\n square: 'aspect-square',\n video: 'aspect-video',\n },\n },\n})\n\n/* `ratio` carries both the named steps and a raw number, which `cva`'s own\n * variant type cannot express - its keys are strings, not numbers. The prop\n * is widened here and split apart in the component instead. */\ntype AspectRatioVariant = VariantProps<typeof aspectRatioVariants>['ratio'] | number\n\nexport interface AspectRatioProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The box's shape: `square`, `video`, or a raw number as width / height -\n * `4 / 3`, `21 / 9` - for anything the named steps do not cover. */\n ratio?: AspectRatioVariant\n children?: ReactNode\n}\n\n/** A box that keeps its shape before, during and after whatever fills it\n * loads. `size-full` on the child is the contract: this component reserves\n * the rectangle, the child covers it. */\nexport function AspectRatio({ ratio, className, children, style, ...props }: AspectRatioProps) {\n const named = typeof ratio === 'number' ? undefined : ratio\n const numeric = typeof ratio === 'number' ? ratio : undefined\n\n return (\n <div\n className={cn(aspectRatioVariants({ ratio: named }), className)}\n style={numeric ? { aspectRatio: numeric, ...style } : style}\n {...props}\n >\n {children}\n </div>\n )\n}\n\nexport const imageVariants = cva('size-full', {\n variants: {\n /** How the picture fills a box that is not its own shape. `cover` for a\n * photo standing in for the whole box; `contain` for a logo or a\n * diagram where cropping would cut off the point of it. */\n fit: {\n cover: 'object-cover',\n contain: 'object-contain',\n },\n },\n defaultVariants: { fit: 'cover' },\n})\n\nexport interface ImageProps\n extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'onLoad' | 'onError' | 'placeholder'>,\n VariantProps<typeof imageVariants> {\n /** Required, with no default: a decorative picture says so explicitly with\n * `alt=\"\"` rather than by omission, which is indistinguishable from a\n * picture nobody described yet. */\n alt: string\n /** What stands in the box when the picture fails to load - the product's\n * text or icon, not a default this component would have to write in some\n * one language. */\n fallback?: ReactNode\n}\n\n/** A picture that reserves its box, shows `Skeleton`'s placeholder while it\n * loads, fades in once it has, and shows `fallback` instead of a broken-image\n * glyph if it can't be loaded at all. */\nexport function Image({ fallback, fit, className, src, alt, ...props }: ImageProps) {\n const [status, setStatus] = useState<'loading' | 'loaded' | 'failed'>('loading')\n const ref = useRef<HTMLImageElement>(null)\n\n // A picture already in the browser's cache is already `complete` the\n // instant this mounts, and fires no `load` event of its own - there is\n // nothing left to load. Without this check the placeholder above it would\n // never be told to leave. `useLayoutEffect` rather than `useEffect`, so a\n // cached picture never paints its placeholder for even one frame - only\n // the layout-timed effect runs before the browser has painted.\n useLayoutEffect(() => {\n const img = ref.current\n setStatus(img && img.complete && img.naturalWidth > 0 ? 'loaded' : 'loading')\n }, [src])\n\n return (\n <span className=\"relative block size-full overflow-hidden\">\n {status !== 'failed' && (\n <img\n {...props}\n ref={ref}\n src={src}\n alt={alt}\n onLoad={() => setStatus('loaded')}\n onError={() => setStatus('failed')}\n className={cn(\n imageVariants({ fit }),\n 'absolute inset-0 transition-opacity duration-base ease-out',\n status === 'loaded' ? 'opacity-100' : 'opacity-0',\n className,\n )}\n // Hidden from a reader until there is a picture to announce - an\n // `alt` describing content that is not there yet, or never\n // arrives, is worse than saying nothing.\n aria-hidden={status !== 'loaded'}\n />\n )}\n {status === 'loading' && (\n <span aria-hidden className=\"absolute inset-0 animate-pulse motion-reduce:animate-none rounded-md bg-soft\" />\n )}\n {status === 'failed' && fallback && (\n <span className=\"absolute inset-0 flex items-center justify-center text-dim\">{fallback}</span>\n )}\n </span>\n )\n}\n"
|
|
1234
1234
|
}
|
|
1235
1235
|
]
|
|
1236
1236
|
},
|
|
@@ -1240,7 +1240,7 @@
|
|
|
1240
1240
|
"title": "InlineField",
|
|
1241
1241
|
"description": "A value that is edited where it is shown: click it, type, Enter or leave to keep, Escape to take it back.",
|
|
1242
1242
|
"dependencies": [
|
|
1243
|
-
"dowel-ui@^0.
|
|
1243
|
+
"dowel-ui@^0.37.0"
|
|
1244
1244
|
],
|
|
1245
1245
|
"registryDependencies": [
|
|
1246
1246
|
"https://lacodda.github.io/dowel/r/field-draft.json"
|
|
@@ -1260,7 +1260,7 @@
|
|
|
1260
1260
|
"title": "Input",
|
|
1261
1261
|
"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.",
|
|
1262
1262
|
"dependencies": [
|
|
1263
|
-
"dowel-ui@^0.
|
|
1263
|
+
"dowel-ui@^0.37.0"
|
|
1264
1264
|
],
|
|
1265
1265
|
"registryDependencies": [],
|
|
1266
1266
|
"files": [
|
|
@@ -1294,7 +1294,7 @@
|
|
|
1294
1294
|
"title": "JsonViewer",
|
|
1295
1295
|
"description": "What a product reaches for when it has to show a response, a settings file, a webhook payload - data the reader needs to understand, not edit. The alternative it replaces is `JSON.stringify(value, null, 2)` inside a `<pre>`, which is fine for twenty lines and useless for two hundred: nothing folds, nothing is findable, and the shape of the document is somewhere inside the indentation.",
|
|
1296
1296
|
"dependencies": [
|
|
1297
|
-
"dowel-ui@^0.
|
|
1297
|
+
"dowel-ui@^0.37.0"
|
|
1298
1298
|
],
|
|
1299
1299
|
"registryDependencies": [
|
|
1300
1300
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1314,7 +1314,7 @@
|
|
|
1314
1314
|
"title": "Kbd",
|
|
1315
1315
|
"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.",
|
|
1316
1316
|
"dependencies": [
|
|
1317
|
-
"dowel-ui@^0.
|
|
1317
|
+
"dowel-ui@^0.37.0"
|
|
1318
1318
|
],
|
|
1319
1319
|
"registryDependencies": [
|
|
1320
1320
|
"https://lacodda.github.io/dowel/r/shortcut.json"
|
|
@@ -1335,7 +1335,7 @@
|
|
|
1335
1335
|
"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.",
|
|
1336
1336
|
"dependencies": [
|
|
1337
1337
|
"class-variance-authority",
|
|
1338
|
-
"dowel-ui@^0.
|
|
1338
|
+
"dowel-ui@^0.37.0"
|
|
1339
1339
|
],
|
|
1340
1340
|
"registryDependencies": [],
|
|
1341
1341
|
"files": [
|
|
@@ -1370,7 +1370,7 @@
|
|
|
1370
1370
|
"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.",
|
|
1371
1371
|
"dependencies": [
|
|
1372
1372
|
"class-variance-authority",
|
|
1373
|
-
"dowel-ui@^0.
|
|
1373
|
+
"dowel-ui@^0.37.0"
|
|
1374
1374
|
],
|
|
1375
1375
|
"registryDependencies": [
|
|
1376
1376
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1407,7 +1407,7 @@
|
|
|
1407
1407
|
"description": "A row of a list, one thing said in a line: ListRow for a fact, RowButton for a choice.",
|
|
1408
1408
|
"dependencies": [
|
|
1409
1409
|
"@base-ui/react",
|
|
1410
|
-
"dowel-ui@^0.
|
|
1410
|
+
"dowel-ui@^0.37.0"
|
|
1411
1411
|
],
|
|
1412
1412
|
"registryDependencies": [],
|
|
1413
1413
|
"files": [
|
|
@@ -1425,7 +1425,7 @@
|
|
|
1425
1425
|
"title": "MarkedText",
|
|
1426
1426
|
"description": "A textarea cannot colour a word. The way round it is older than React: draw the same text twice, once as marked-up HTML underneath and once as the textarea on top with its own text transparent, so the caret and the selection are the browser's and the colours are ours. The two have to agree on every metric - font, size, line height, padding, wrapping - or the marks slide off the words they mark. So both take ONE class list, given by the caller, and the textarea adds only what makes it invisible.",
|
|
1427
1427
|
"dependencies": [
|
|
1428
|
-
"dowel-ui@^0.
|
|
1428
|
+
"dowel-ui@^0.37.0"
|
|
1429
1429
|
],
|
|
1430
1430
|
"registryDependencies": [],
|
|
1431
1431
|
"files": [
|
|
@@ -1445,7 +1445,7 @@
|
|
|
1445
1445
|
"dependencies": [
|
|
1446
1446
|
"@base-ui/react",
|
|
1447
1447
|
"class-variance-authority",
|
|
1448
|
-
"dowel-ui@^0.
|
|
1448
|
+
"dowel-ui@^0.37.0"
|
|
1449
1449
|
],
|
|
1450
1450
|
"registryDependencies": [
|
|
1451
1451
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1467,7 +1467,7 @@
|
|
|
1467
1467
|
"dependencies": [
|
|
1468
1468
|
"@base-ui/react",
|
|
1469
1469
|
"class-variance-authority",
|
|
1470
|
-
"dowel-ui@^0.
|
|
1470
|
+
"dowel-ui@^0.37.0"
|
|
1471
1471
|
],
|
|
1472
1472
|
"registryDependencies": [
|
|
1473
1473
|
"https://lacodda.github.io/dowel/r/tooltip.json"
|
|
@@ -1487,7 +1487,7 @@
|
|
|
1487
1487
|
"title": "NotificationBell",
|
|
1488
1488
|
"description": "A bell that is always lit is a bell nobody reads, so the count is the product's decision and this draws it: nothing at zero, the number past that, `9+` past nine. Pressing it does not leave the screen - the last few entries open under it and the whole history is one more click, which is the shape every product converged on once the first one tried a page.",
|
|
1489
1489
|
"dependencies": [
|
|
1490
|
-
"dowel-ui@^0.
|
|
1490
|
+
"dowel-ui@^0.37.0"
|
|
1491
1491
|
],
|
|
1492
1492
|
"registryDependencies": [
|
|
1493
1493
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1509,7 +1509,7 @@
|
|
|
1509
1509
|
"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.",
|
|
1510
1510
|
"dependencies": [
|
|
1511
1511
|
"@base-ui/react",
|
|
1512
|
-
"dowel-ui@^0.
|
|
1512
|
+
"dowel-ui@^0.37.0"
|
|
1513
1513
|
],
|
|
1514
1514
|
"registryDependencies": [
|
|
1515
1515
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1519,7 +1519,7 @@
|
|
|
1519
1519
|
"path": "ui/number-field.tsx",
|
|
1520
1520
|
"target": "@ui/number-field.tsx",
|
|
1521
1521
|
"type": "registry:ui",
|
|
1522
|
-
"content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the application's\n * language (see `useLocale`); a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: string\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n locale,\n ...props\n}: NumberFieldProps) {\n const language = useLocale(locale)\n return (\n <Base.Root {...
|
|
1522
|
+
"content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** Keep a typed number outside `min`/`max` as typed, rather than moving it\n * to the bound on blur - for a form that says what is wrong instead. A 9\n * typed for an age of at least 13 became 13 without a word; the arrows\n * still stop at the bounds. */\n allowOutOfRange?: boolean\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the application's\n * language (see `useLocale`); a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: string\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n locale,\n ...props\n}: NumberFieldProps) {\n const language = useLocale(locale)\n /* What a `Field` hands its control - the id and the `aria-` references - is\n * already on the input, put there by Base UI from the field it sits in.\n * Spread onto the root as well, it landed on a `<div>` with no role, where\n * `aria-labelledby` and `aria-invalid` mean nothing and axe asks a person\n * to look (aria-prohibited-attr). */\n const root: Record<string, unknown> = { ...props }\n for (const key of ['id', 'aria-labelledby', 'aria-describedby', 'aria-invalid']) delete root[key]\n return (\n <Base.Root {...root} locale={language} className={cn('inline-flex items-center gap-2', className)}>\n <Base.Group\n className={cn(\n fieldClasses,\n 'flex h-control items-stretch overflow-hidden p-0',\n // The group carries the field's clothes, so the focus ring belongs\n // to the whole control rather than to the bare input inside it.\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n )}\n >\n {!hideStepper && (\n <Base.Decrement className={cn(stepperButton, 'border-r border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Decrement>\n )}\n\n <Base.Input\n placeholder={placeholder}\n aria-label={ariaLabel}\n className={cn(\n // 16px under a finger, for the reason `fieldClasses` gives.\n 'w-full min-w-0 bg-transparent px-2.5 text-sm pointer-coarse:text-lg text-text placeholder:text-faint',\n 'outline-none',\n // Figures line up in a column, which is the whole reason a number\n // is in a field of its own.\n 'tabular-nums',\n hideStepper ? 'text-left' : 'text-center',\n )}\n />\n\n {!hideStepper && (\n <Base.Increment className={cn(stepperButton, 'border-l border-line')} aria-hidden>\n <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n <path d=\"M8 4v8M4 8h8\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n </svg>\n </Base.Increment>\n )}\n </Base.Group>\n\n {unit !== undefined && <span className=\"shrink-0 text-xs text-dim\">{unit}</span>}\n </Base.Root>\n )\n}\n"
|
|
1523
1523
|
}
|
|
1524
1524
|
]
|
|
1525
1525
|
},
|
|
@@ -1529,7 +1529,7 @@
|
|
|
1529
1529
|
"title": "NumberFormat",
|
|
1530
1530
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1531
1531
|
"dependencies": [
|
|
1532
|
-
"dowel-ui@^0.
|
|
1532
|
+
"dowel-ui@^0.37.0"
|
|
1533
1533
|
],
|
|
1534
1534
|
"registryDependencies": [],
|
|
1535
1535
|
"files": [
|
|
@@ -1548,7 +1548,7 @@
|
|
|
1548
1548
|
"description": "What a screen says it is, and the buttons that act on the whole of it: a title, a line under it, and the actions at the far end of the same row.",
|
|
1549
1549
|
"dependencies": [
|
|
1550
1550
|
"class-variance-authority",
|
|
1551
|
-
"dowel-ui@^0.
|
|
1551
|
+
"dowel-ui@^0.37.0"
|
|
1552
1552
|
],
|
|
1553
1553
|
"registryDependencies": [],
|
|
1554
1554
|
"files": [
|
|
@@ -1566,7 +1566,7 @@
|
|
|
1566
1566
|
"title": "PageSize",
|
|
1567
1567
|
"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.",
|
|
1568
1568
|
"dependencies": [
|
|
1569
|
-
"dowel-ui@^0.
|
|
1569
|
+
"dowel-ui@^0.37.0"
|
|
1570
1570
|
],
|
|
1571
1571
|
"registryDependencies": [
|
|
1572
1572
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1586,7 +1586,7 @@
|
|
|
1586
1586
|
"title": "Pagination",
|
|
1587
1587
|
"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.",
|
|
1588
1588
|
"dependencies": [
|
|
1589
|
-
"dowel-ui@^0.
|
|
1589
|
+
"dowel-ui@^0.37.0"
|
|
1590
1590
|
],
|
|
1591
1591
|
"registryDependencies": [
|
|
1592
1592
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1607,7 +1607,7 @@
|
|
|
1607
1607
|
"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.",
|
|
1608
1608
|
"dependencies": [
|
|
1609
1609
|
"class-variance-authority",
|
|
1610
|
-
"dowel-ui@^0.
|
|
1610
|
+
"dowel-ui@^0.37.0"
|
|
1611
1611
|
],
|
|
1612
1612
|
"registryDependencies": [],
|
|
1613
1613
|
"files": [
|
|
@@ -1625,7 +1625,7 @@
|
|
|
1625
1625
|
"title": "PasswordField",
|
|
1626
1626
|
"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.",
|
|
1627
1627
|
"dependencies": [
|
|
1628
|
-
"dowel-ui@^0.
|
|
1628
|
+
"dowel-ui@^0.37.0"
|
|
1629
1629
|
],
|
|
1630
1630
|
"registryDependencies": [
|
|
1631
1631
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1647,7 +1647,7 @@
|
|
|
1647
1647
|
"dependencies": [
|
|
1648
1648
|
"@base-ui/react",
|
|
1649
1649
|
"class-variance-authority",
|
|
1650
|
-
"dowel-ui@^0.
|
|
1650
|
+
"dowel-ui@^0.37.0"
|
|
1651
1651
|
],
|
|
1652
1652
|
"registryDependencies": [
|
|
1653
1653
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1669,7 +1669,7 @@
|
|
|
1669
1669
|
"dependencies": [
|
|
1670
1670
|
"@base-ui/react",
|
|
1671
1671
|
"class-variance-authority",
|
|
1672
|
-
"dowel-ui@^0.
|
|
1672
|
+
"dowel-ui@^0.37.0"
|
|
1673
1673
|
],
|
|
1674
1674
|
"registryDependencies": [
|
|
1675
1675
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1689,7 +1689,7 @@
|
|
|
1689
1689
|
"title": "ProductMark",
|
|
1690
1690
|
"description": "The product-mark primitive.",
|
|
1691
1691
|
"dependencies": [
|
|
1692
|
-
"dowel-ui@^0.
|
|
1692
|
+
"dowel-ui@^0.37.0"
|
|
1693
1693
|
],
|
|
1694
1694
|
"registryDependencies": [],
|
|
1695
1695
|
"files": [
|
|
@@ -1707,7 +1707,7 @@
|
|
|
1707
1707
|
"title": "ProductSwitcher",
|
|
1708
1708
|
"description": "The line's products are meant to be used together: kasl-server reads what kasl records, rigger plans what furca commits, a note in scheda cites a track in lyrid. A person moving between them was typing addresses from memory. This is the one place that knows where the others are - the current product's small tile and name on the trigger, and the others in a menu, each with its own tile, so the colour a person already knows the product by is what they look for.",
|
|
1709
1709
|
"dependencies": [
|
|
1710
|
-
"dowel-ui@^0.
|
|
1710
|
+
"dowel-ui@^0.37.0"
|
|
1711
1711
|
],
|
|
1712
1712
|
"registryDependencies": [
|
|
1713
1713
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
@@ -1730,7 +1730,7 @@
|
|
|
1730
1730
|
"dependencies": [
|
|
1731
1731
|
"@base-ui/react",
|
|
1732
1732
|
"class-variance-authority",
|
|
1733
|
-
"dowel-ui@^0.
|
|
1733
|
+
"dowel-ui@^0.37.0"
|
|
1734
1734
|
],
|
|
1735
1735
|
"registryDependencies": [],
|
|
1736
1736
|
"files": [
|
|
@@ -1770,7 +1770,7 @@
|
|
|
1770
1770
|
"dependencies": [
|
|
1771
1771
|
"@base-ui/react",
|
|
1772
1772
|
"class-variance-authority",
|
|
1773
|
-
"dowel-ui@^0.
|
|
1773
|
+
"dowel-ui@^0.37.0"
|
|
1774
1774
|
],
|
|
1775
1775
|
"registryDependencies": [],
|
|
1776
1776
|
"files": [
|
|
@@ -1788,7 +1788,7 @@
|
|
|
1788
1788
|
"title": "RatingScale",
|
|
1789
1789
|
"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\".",
|
|
1790
1790
|
"dependencies": [
|
|
1791
|
-
"dowel-ui@^0.
|
|
1791
|
+
"dowel-ui@^0.37.0"
|
|
1792
1792
|
],
|
|
1793
1793
|
"registryDependencies": [],
|
|
1794
1794
|
"files": [
|
|
@@ -1806,7 +1806,7 @@
|
|
|
1806
1806
|
"title": "RelativeTime",
|
|
1807
1807
|
"description": "The relative-time primitive.",
|
|
1808
1808
|
"dependencies": [
|
|
1809
|
-
"dowel-ui@^0.
|
|
1809
|
+
"dowel-ui@^0.37.0"
|
|
1810
1810
|
],
|
|
1811
1811
|
"registryDependencies": [],
|
|
1812
1812
|
"files": [
|
|
@@ -1824,7 +1824,7 @@
|
|
|
1824
1824
|
"title": "ReorderableList",
|
|
1825
1825
|
"description": "The columns in a column picker, the stops of a dial, the roles of a profile. A hook and a grip rather than a list component: the rows are already something else's - a menu's items, a form's fields - and a component wrapping them would have to reproduce whatever that something else does.",
|
|
1826
1826
|
"dependencies": [
|
|
1827
|
-
"dowel-ui@^0.
|
|
1827
|
+
"dowel-ui@^0.37.0"
|
|
1828
1828
|
],
|
|
1829
1829
|
"registryDependencies": [],
|
|
1830
1830
|
"files": [
|
|
@@ -1842,7 +1842,7 @@
|
|
|
1842
1842
|
"title": "SaveState",
|
|
1843
1843
|
"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.",
|
|
1844
1844
|
"dependencies": [
|
|
1845
|
-
"dowel-ui@^0.
|
|
1845
|
+
"dowel-ui@^0.37.0"
|
|
1846
1846
|
],
|
|
1847
1847
|
"registryDependencies": [
|
|
1848
1848
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1856,6 +1856,73 @@
|
|
|
1856
1856
|
}
|
|
1857
1857
|
]
|
|
1858
1858
|
},
|
|
1859
|
+
{
|
|
1860
|
+
"name": "schema-check",
|
|
1861
|
+
"type": "registry:ui",
|
|
1862
|
+
"title": "SchemaCheck",
|
|
1863
|
+
"description": "The other half of `schema-shape`: that file reads which fields there are, this one holds a value against them. It answers in codes - `minimum`, with the limit - and never in words, because the words are the product's and in the product's language; `SchemaFields` asks the product to say each one.",
|
|
1864
|
+
"dependencies": [],
|
|
1865
|
+
"registryDependencies": [
|
|
1866
|
+
"https://lacodda.github.io/dowel/r/schema-shape.json"
|
|
1867
|
+
],
|
|
1868
|
+
"files": [
|
|
1869
|
+
{
|
|
1870
|
+
"path": "ui/schema-check.tsx",
|
|
1871
|
+
"target": "@ui/schema-check.tsx",
|
|
1872
|
+
"type": "registry:ui",
|
|
1873
|
+
"content": "import { patternOf, type SchemaField, type SchemaRules } from './schema-shape'\n\n/*\n * What is wrong with a value, by the rules its schema states.\n *\n * The other half of `schema-shape`: that file reads which fields there are,\n * this one holds a value against them. It answers in codes - `minimum`, with\n * the limit - and never in words, because the words are the product's and in\n * the product's language; `SchemaFields` asks the product to say each one.\n *\n * It checks what the form draws and nothing more. A format it does not know\n * (`hostname`, `uuid`) is drawn as text and not checked here, and a rule the\n * schema cannot state - a zod `refine`, a name that is already taken - arrives\n * as an error from outside. This is the check that lets a form refuse a value\n * before it is sent, not a replacement for the one the receiving end makes.\n */\n\ntype Limit =\n | 'minLength'\n | 'maxLength'\n | 'minimum'\n | 'maximum'\n | 'exclusiveMinimum'\n | 'exclusiveMaximum'\n | 'multipleOf'\n | 'minItems'\n | 'maxItems'\n\n/** One problem with one field. `key` is the field's; `item` is set when the\n * problem is one entry of a list of tags. */\nexport type SchemaIssue = { key: string; item?: string } & (\n | { code: 'required' | 'type' | 'enum' | 'uniqueItems' }\n | { code: 'format'; format: string }\n | { code: 'pattern'; pattern: string }\n | { code: Limit; limit: number }\n)\n\n/** The value at a path, or `undefined` if any step of the way is missing. */\nexport function valueAt(value: unknown, path: readonly string[]): unknown {\n let at = value\n for (const name of path) {\n if (typeof at !== 'object' || at === null) return undefined\n at = (at as Record<string, unknown>)[name]\n }\n return at\n}\n\n/** A copy of the object with the value at the path replaced - or the key\n * removed, for `undefined`. The objects on the way are copied, never\n * changed, so a product's state stays a value React can compare. */\nexport function withValue(value: Record<string, unknown>, path: readonly string[], next: unknown) {\n const [name, ...rest] = path\n const copy = { ...value }\n if (name === undefined) return copy\n if (rest.length > 0) {\n const inner = copy[name]\n copy[name] = withValue(typeof inner === 'object' && inner !== null ? (inner as Record<string, unknown>) : {}, rest, next)\n } else if (next === undefined) {\n delete copy[name]\n } else {\n copy[name] = next\n }\n return copy\n}\n\n/** Every problem with the value, field by field, in the order they are drawn;\n * the first for a field is the one worth saying. */\nexport function checkValue(fields: readonly SchemaField[], value: unknown): SchemaIssue[] {\n return fields.flatMap((field) => checkField(field, valueAt(value, field.path), value))\n}\n\nfunction checkField(field: SchemaField, value: unknown, root: unknown): SchemaIssue[] {\n const { key } = field\n if (field.control === 'custom' || field.fixed) return []\n // A group is checked through its fields, present or not: a required group\n // is there by construction (`defaultsOf` makes it), and an optional one\n // that is absent asks nothing of what would be inside it.\n if (field.control === 'group') {\n if (value === undefined && !field.required) return []\n return checkValue(field.fields ?? [], root)\n }\n if (value === undefined || value === '' || (value === null && !field.nullable)) {\n return field.required ? [{ key, code: 'required' }] : []\n }\n if (value === null) return []\n\n switch (field.control) {\n case 'number':\n case 'integer':\n if (typeof value !== 'number' || !Number.isFinite(value)) return [{ key, code: 'type' }]\n if (field.control === 'integer' && !Number.isInteger(value)) return [{ key, code: 'type' }]\n return numberIssues(key, value, field.rules)\n case 'boolean':\n return typeof value === 'boolean' ? [] : [{ key, code: 'type' }]\n case 'choice':\n case 'segmented':\n case 'radio':\n return field.options?.some((option) => option.value === value) ? [] : [{ key, code: 'enum' }]\n case 'choices':\n case 'tags': {\n if (!Array.isArray(value)) return [{ key, code: 'type' }]\n const issues = listIssues(key, value, field.rules)\n for (const item of value) {\n if (field.control === 'choices') {\n if (!field.options?.some((option) => option.value === item)) issues.push({ key, code: 'enum' })\n } else if (typeof item !== 'string') {\n issues.push({ key, code: 'type' })\n } else {\n issues.push(...textIssues(key, item, field.itemRules ?? {}).map((issue) => ({ ...issue, item })))\n }\n }\n return issues\n }\n default:\n return typeof value === 'string' ? textIssues(key, value, field.rules) : [{ key, code: 'type' }]\n }\n}\n\n/** Formats a reader can be told about. Deliberately loose: an address with an\n * `@` and no spaces is an address to try, and the mail server is the judge of\n * the rest - a strict pattern refuses real addresses, which is worse. */\nconst FORMATS: Record<string, (value: string) => boolean> = {\n email: (value) => /^[^\\s@]+@[^\\s@]+$/.test(value),\n uri: (value) => URL.canParse(value),\n date: (value) => {\n const match = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value)\n if (!match) return false\n const [year, month, day] = match.slice(1).map(Number) as [number, number, number]\n const date = new Date(Date.UTC(year, month - 1, day))\n return date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n },\n}\n\nfunction textIssues(key: string, value: string, rules: SchemaRules): SchemaIssue[] {\n const issues: SchemaIssue[] = []\n // Characters as a reader counts them, which is what JSON Schema counts: an\n // emoji is one, not the two UTF-16 units `length` sees.\n const length = [...value].length\n if (rules.minLength !== undefined && length < rules.minLength) issues.push({ key, code: 'minLength', limit: rules.minLength })\n if (rules.maxLength !== undefined && length > rules.maxLength) issues.push({ key, code: 'maxLength', limit: rules.maxLength })\n const known = rules.format === undefined ? undefined : FORMATS[rules.format]\n if (known && !known(value)) issues.push({ key, code: 'format', format: rules.format! })\n if (rules.pattern !== undefined && !patternOf(rules.pattern, key).test(value)) {\n issues.push({ key, code: 'pattern', pattern: rules.pattern })\n }\n return issues\n}\n\nfunction numberIssues(key: string, value: number, rules: SchemaRules): SchemaIssue[] {\n const issues: SchemaIssue[] = []\n if (rules.minimum !== undefined && value < rules.minimum) issues.push({ key, code: 'minimum', limit: rules.minimum })\n if (rules.maximum !== undefined && value > rules.maximum) issues.push({ key, code: 'maximum', limit: rules.maximum })\n if (rules.exclusiveMinimum !== undefined && value <= rules.exclusiveMinimum) {\n issues.push({ key, code: 'exclusiveMinimum', limit: rules.exclusiveMinimum })\n }\n if (rules.exclusiveMaximum !== undefined && value >= rules.exclusiveMaximum) {\n issues.push({ key, code: 'exclusiveMaximum', limit: rules.exclusiveMaximum })\n }\n // A quotient within a hair of a whole number is a whole number: 0.3 / 0.1 is\n // 2.9999999999999996 in floating point, and 0.3 is a multiple of 0.1.\n if (rules.multipleOf !== undefined) {\n const quotient = value / rules.multipleOf\n if (Math.abs(quotient - Math.round(quotient)) > 1e-9) issues.push({ key, code: 'multipleOf', limit: rules.multipleOf })\n }\n return issues\n}\n\nfunction listIssues(key: string, value: readonly unknown[], rules: SchemaRules): SchemaIssue[] {\n const issues: SchemaIssue[] = []\n if (rules.minItems !== undefined && value.length < rules.minItems) issues.push({ key, code: 'minItems', limit: rules.minItems })\n if (rules.maxItems !== undefined && value.length > rules.maxItems) issues.push({ key, code: 'maxItems', limit: rules.maxItems })\n if (rules.uniqueItems && new Set(value).size < value.length) issues.push({ key, code: 'uniqueItems' })\n return issues\n}\n"
|
|
1874
|
+
}
|
|
1875
|
+
]
|
|
1876
|
+
},
|
|
1877
|
+
{
|
|
1878
|
+
"name": "schema-fields",
|
|
1879
|
+
"type": "registry:ui",
|
|
1880
|
+
"title": "SchemaFields",
|
|
1881
|
+
"description": "A settings screen, a profile, a config: the data is already described once, as a zod schema or a Rust struct, and the form beside it was the second description - the one that fell behind. This draws the form from the first one. zod hands it a schema with `z.toJSONSchema()`, a Rust struct with `schemars`; no adapter for either, because the schema is the adapter. Which field becomes which control is `schema-shape`'s reading, and what is wrong with a value is `schema-check`'s.",
|
|
1882
|
+
"dependencies": [
|
|
1883
|
+
"dowel-ui@^0.37.0"
|
|
1884
|
+
],
|
|
1885
|
+
"registryDependencies": [
|
|
1886
|
+
"https://lacodda.github.io/dowel/r/checkbox.json",
|
|
1887
|
+
"https://lacodda.github.io/dowel/r/date-picker.json",
|
|
1888
|
+
"https://lacodda.github.io/dowel/r/field.json",
|
|
1889
|
+
"https://lacodda.github.io/dowel/r/input.json",
|
|
1890
|
+
"https://lacodda.github.io/dowel/r/number-field.json",
|
|
1891
|
+
"https://lacodda.github.io/dowel/r/password-field.json",
|
|
1892
|
+
"https://lacodda.github.io/dowel/r/radio-group.json",
|
|
1893
|
+
"https://lacodda.github.io/dowel/r/schema-check.json",
|
|
1894
|
+
"https://lacodda.github.io/dowel/r/schema-shape.json",
|
|
1895
|
+
"https://lacodda.github.io/dowel/r/segmented-control.json",
|
|
1896
|
+
"https://lacodda.github.io/dowel/r/select.json",
|
|
1897
|
+
"https://lacodda.github.io/dowel/r/switch.json",
|
|
1898
|
+
"https://lacodda.github.io/dowel/r/tag-input.json",
|
|
1899
|
+
"https://lacodda.github.io/dowel/r/textarea.json"
|
|
1900
|
+
],
|
|
1901
|
+
"files": [
|
|
1902
|
+
{
|
|
1903
|
+
"path": "ui/schema-fields.tsx",
|
|
1904
|
+
"target": "@ui/schema-fields.tsx",
|
|
1905
|
+
"type": "registry:ui",
|
|
1906
|
+
"content": "import { useMemo, useState, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Field, FieldGroup } from './field'\nimport { Input } from './input'\nimport { Textarea } from './textarea'\nimport { PasswordField } from './password-field'\nimport { NumberField } from './number-field'\nimport { Switch } from './switch'\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from './select'\nimport { SegmentedControl, Segment } from './segmented-control'\nimport { Radio, RadioGroup } from './radio-group'\nimport { Checkbox, CheckboxGroup } from './checkbox'\nimport { TagInput } from './tag-input'\nimport { DatePicker } from './date-picker'\nimport { readSchema, type JsonSchema, type SchemaField, type SchemaOption } from './schema-shape'\nimport { checkValue, valueAt, withValue, type SchemaIssue } from './schema-check'\n\n/*\n * SchemaFields - the fields a JSON Schema describes, drawn and checked.\n *\n * A settings screen, a profile, a config: the data is already described once,\n * as a zod schema or a Rust struct, and the form beside it was the second\n * description - the one that fell behind. This draws the form from the first\n * one. zod hands it a schema with `z.toJSONSchema()`, a Rust struct with\n * `schemars`; no adapter for either, because the schema is the adapter. Which\n * field becomes which control is `schema-shape`'s reading, and what is wrong\n * with a value is `schema-check`'s.\n *\n * Every field is a `Field` or a `FieldGroup`, so the label, the hint and the\n * error are wired the one way the set wires them. The value is the product's:\n * one object in, the next object out, nothing kept here but which fields have\n * been left - a problem is said once a field has been left, or for all of them\n * at once after a refused submit (`reveal`). An empty box writes nothing: the\n * key is removed, or set to `null` where the schema allows `null`, so an\n * optional field left empty is absent rather than an empty string that fails\n * its own pattern.\n *\n * It has no words of its own. What a problem is called, what the empty choice\n * says, what the date picker's buttons are named - all `words`, in the\n * product's language. A field the schema cannot describe well enough is the\n * product's to draw (`custom`); one it cannot describe at all is refused by\n * name when the schema is read, never silently left out.\n */\n\n/** Every word the fields can show, in the product's language. */\nexport interface SchemaFieldsWords {\n /** What is wrong, said for one field: `issue.code` names the rule, `limit`\n * and `format` carry what it asked for. */\n issue: (issue: SchemaIssue, field: SchemaField) => ReactNode\n /** The answer that takes an optional choice back: \"None\". */\n none: string\n /** What a required choice says before anything is chosen: \"Choose…\". */\n choose: string\n /** The date picker's empty face and its two month buttons. */\n pickDate: string\n previousMonth: string\n nextMonth: string\n /** The secret's reveal button, while hidden and while showing. */\n showSecret: string\n hideSecret: string\n /** Names the cross on one tag: \"Remove {tag}\". */\n removeTag: (tag: string) => string\n}\n\n/** What a product's own drawing of a field is handed. */\nexport interface SchemaCustomProps {\n field: SchemaField\n value: unknown\n onValueChange: (value: unknown) => void\n error: ReactNode\n disabled: boolean\n}\n\nexport interface SchemaFieldsProps {\n /** An object schema: its properties are the fields, in the order written. */\n schema: JsonSchema\n value: Readonly<Record<string, unknown>>\n onValueChange: (value: Record<string, unknown>) => void\n words: SchemaFieldsWords\n /** Problems from elsewhere - the server, a rule the schema cannot state - by\n * key (`server.port`). Said at once, and over the schema's own. */\n errors?: Readonly<Record<string, ReactNode>>\n /** Say every problem now, left or not - after a submit that was refused. */\n reveal?: boolean\n /** Fields the product draws itself, by key. Read for their label and help;\n * never refused, never checked here. */\n custom?: Readonly<Record<string, (props: SchemaCustomProps) => ReactNode>>\n disabled?: boolean\n className?: string\n}\n\n/** An option's value as the string a control carries. `JSON.stringify` keeps\n * `1` and `'1'` apart, and never yields the empty string the \"none\" answer\n * takes. */\nconst keyOf = (value: unknown) => JSON.stringify(value)\n\nexport function SchemaFields({\n schema,\n value,\n onValueChange,\n words,\n errors,\n reveal = false,\n custom,\n disabled = false,\n className,\n}: SchemaFieldsProps) {\n // The keys of `custom` rather than the object: they are what reading\n // depends on, and an inline object is a new identity on every render.\n const drawn = Object.keys(custom ?? {}).join('\\n')\n const fields = useMemo(() => readSchema(schema, drawn ? drawn.split('\\n') : []), [schema, drawn])\n const [left, setLeft] = useState<ReadonlySet<string>>(new Set())\n\n const issues = useMemo(() => {\n const first = new Map<string, SchemaIssue>()\n for (const issue of checkValue(fields, value)) if (!first.has(issue.key)) first.set(issue.key, issue)\n return first\n }, [fields, value])\n\n const draw = (field: SchemaField): ReactNode => {\n if (field.fixed) return null\n if (field.control === 'group') {\n return (\n <FieldGroup key={field.key} label={field.label} help={field.help} required={field.required} disabled={disabled}>\n <div className=\"flex flex-col gap-4\">{field.fields?.map(draw)}</div>\n </FieldGroup>\n )\n }\n const issue = issues.get(field.key)\n const error =\n errors?.[field.key] ?? (issue && (reveal || left.has(field.key)) ? words.issue(issue, field) : undefined)\n const set = (next: unknown) => onValueChange(withValue(value, field.path, next))\n return (\n <div\n key={field.key}\n // Left means focus went somewhere outside the field, not from its box\n // to its own reveal button.\n onBlur={(event) => {\n if (event.currentTarget.contains(event.relatedTarget as Node | null)) return\n setLeft((keys) => (keys.has(field.key) ? keys : new Set(keys).add(field.key)))\n }}\n >\n {control(field, valueAt(value, field.path), set, error)}\n </div>\n )\n }\n\n const control = (field: SchemaField, current: unknown, set: (next: unknown) => void, error: ReactNode) => {\n const empty = field.nullable ? null : undefined\n const group = { label: field.label, help: field.help, required: field.required, error, disabled }\n const shared = { ...group, name: field.key }\n const text = typeof current === 'string' ? current : ''\n const options = field.options ?? []\n const optionOf = (key: string) => options.find((option) => keyOf(option.value) === key)\n const choose = (key: string | null) => set(key ? optionOf(key)?.value : empty)\n\n switch (field.control) {\n case 'custom':\n return custom?.[field.key]?.({ field, value: current, onValueChange: set, error, disabled })\n case 'text':\n case 'email':\n case 'url':\n return (\n <Field {...shared}>\n <Input\n type={field.control === 'text' ? 'text' : field.control}\n value={text}\n readOnly={field.readOnly}\n required={field.required}\n onChange={(event) => set(event.target.value === '' ? empty : event.target.value)}\n />\n </Field>\n )\n case 'multiline':\n return (\n <Field {...shared}>\n <Textarea\n autoResize\n value={text}\n readOnly={field.readOnly}\n required={field.required}\n onChange={(event) => set(event.target.value === '' ? empty : event.target.value)}\n />\n </Field>\n )\n case 'secret':\n return (\n <Field {...shared}>\n <PasswordField\n value={text}\n // Not a sign-in: a token or a key in a settings screen, which a\n // password manager should neither fill nor offer to generate.\n autoComplete=\"off\"\n showLabel={words.showSecret}\n hideLabel={words.hideSecret}\n readOnly={field.readOnly}\n required={field.required}\n onValueChange={(next) => set(next === '' ? empty : next)}\n />\n </Field>\n )\n case 'date':\n return (\n <Field {...shared}>\n <DatePicker\n value={text || undefined}\n placeholder={words.pickDate}\n previousMonthLabel={words.previousMonth}\n nextMonthLabel={words.nextMonth}\n disabled={disabled || field.readOnly}\n onValueChange={set}\n />\n </Field>\n )\n case 'number':\n case 'integer': {\n const { minimum, maximum, multipleOf } = field.rules\n return (\n <Field {...shared}>\n <NumberField\n value={typeof current === 'number' ? current : null}\n disabled={disabled}\n min={minimum}\n max={maximum}\n // A number out of range is said, not quietly moved to the bound.\n allowOutOfRange\n step={multipleOf ?? (field.control === 'integer' ? 1 : undefined)}\n format={field.control === 'integer' ? { maximumFractionDigits: 0 } : undefined}\n readOnly={field.readOnly}\n required={field.required}\n onValueChange={(next) => set(next === null ? empty : next)}\n />\n </Field>\n )\n }\n case 'boolean':\n return (\n <Field {...shared} beside>\n <Switch\n disabled={disabled}\n // Absent reads as the default: that is what the receiving end\n // will take it to be.\n checked={typeof current === 'boolean' ? current : field.default === true}\n readOnly={field.readOnly}\n onCheckedChange={set}\n />\n </Field>\n )\n case 'choice': {\n const items = [\n ...(field.required ? [] : [{ value: '', label: words.none }]),\n ...options.map((option) => ({ value: keyOf(option.value), label: option.label })),\n ]\n const chosen = current === undefined || current === null ? (field.required ? null : '') : keyOf(current)\n return (\n <Field {...shared}>\n <Select\n items={items}\n value={chosen}\n disabled={disabled}\n readOnly={field.readOnly}\n required={field.required}\n onValueChange={(next: string | null) => choose(next)}\n >\n <SelectTrigger>\n <SelectValue placeholder={words.choose} />\n </SelectTrigger>\n <SelectPopup>\n {items.map((item) => (\n <SelectItem key={item.value} value={item.value}>\n {item.label}\n </SelectItem>\n ))}\n </SelectPopup>\n </Select>\n </Field>\n )\n }\n case 'segmented':\n return (\n <FieldGroup {...group}>\n <SegmentedControl\n aria-label={field.label}\n value={current === undefined || current === null ? '' : keyOf(current)}\n disabled={disabled || field.readOnly}\n onValueChange={choose}\n >\n {options.map((option) => (\n <Segment key={keyOf(option.value)} value={keyOf(option.value)}>\n {option.label}\n </Segment>\n ))}\n </SegmentedControl>\n </FieldGroup>\n )\n case 'radio':\n return (\n <FieldGroup {...group}>\n <RadioGroup\n aria-label={field.label}\n value={current === undefined || current === null ? '' : keyOf(current)}\n readOnly={field.readOnly}\n required={field.required}\n onValueChange={choose}\n >\n {options.map((option) => (\n <Radio key={keyOf(option.value)} value={keyOf(option.value)}>\n <Answer option={option} />\n </Radio>\n ))}\n </RadioGroup>\n </FieldGroup>\n )\n case 'choices': {\n const picked = Array.isArray(current) ? current.map(keyOf) : []\n return (\n <FieldGroup {...group}>\n <CheckboxGroup\n value={picked}\n disabled={disabled || field.readOnly}\n // In the order the schema offers them, whatever order they were\n // ticked in: a stored list that reorders itself on every edit is\n // a diff nobody asked for.\n onValueChange={(next) =>\n set(options.filter((option) => next.includes(keyOf(option.value))).map((option) => option.value))\n }\n >\n {options.map((option) => (\n <Checkbox key={keyOf(option.value)} value={keyOf(option.value)}>\n <Answer option={option} />\n </Checkbox>\n ))}\n </CheckboxGroup>\n </FieldGroup>\n )\n }\n case 'tags':\n return (\n <Field {...shared}>\n <TagInput\n value={Array.isArray(current) ? current.filter((tag) => typeof tag === 'string') : []}\n max={field.rules.maxItems}\n removeLabel={words.removeTag}\n disabled={disabled || field.readOnly}\n onValueChange={set}\n />\n </Field>\n )\n }\n }\n\n return <div className={cn('flex flex-col gap-4', className)}>{fields.map(draw)}</div>\n}\n\n/** An answer, with its own line of help where the schema gives one - a radio\n * or a checkbox has room for it, a select does not. */\nfunction Answer({ option }: { option: SchemaOption }) {\n if (option.help === undefined) return option.label\n return (\n <span className=\"flex flex-col\">\n <span>{option.label}</span>\n <span className=\"text-xs text-dim\">{option.help}</span>\n </span>\n )\n}\n"
|
|
1907
|
+
}
|
|
1908
|
+
]
|
|
1909
|
+
},
|
|
1910
|
+
{
|
|
1911
|
+
"name": "schema-shape",
|
|
1912
|
+
"type": "registry:ui",
|
|
1913
|
+
"title": "SchemaShape",
|
|
1914
|
+
"description": "Settings, a profile, a config file: the description of the data already exists, as a zod schema or a Rust struct, and the form was being written a second time beside it by hand. The two drift - a field added to the struct that nobody can edit, a range the form allows and the server refuses.",
|
|
1915
|
+
"dependencies": [],
|
|
1916
|
+
"registryDependencies": [],
|
|
1917
|
+
"files": [
|
|
1918
|
+
{
|
|
1919
|
+
"path": "ui/schema-shape.tsx",
|
|
1920
|
+
"target": "@ui/schema-shape.tsx",
|
|
1921
|
+
"type": "registry:ui",
|
|
1922
|
+
"content": "export {}\n// Code first, so the description below survives `shadcn add`: the CLI writes\n// a file from its first token on and drops every comment above it.\n\n/*\n * A JSON Schema, read as the fields a form draws - with no React in it.\n *\n * Settings, a profile, a config file: the description of the data already\n * exists, as a zod schema or a Rust struct, and the form was being written a\n * second time beside it by hand. The two drift - a field added to the struct\n * that nobody can edit, a range the form allows and the server refuses.\n * JSON Schema is what both ends already speak: zod writes one with\n * `z.toJSONSchema()`, a Rust struct with `schemars`. So that is what is read\n * here, and no adapter for either library is needed - the schema is the\n * adapter.\n *\n * Reading is strict on purpose. A keyword this cannot draw - a union of\n * objects, a map, a list of records - is refused by name rather than skipped,\n * because a form that silently leaves a field out is a form the data can no\n * longer be edited through, and nobody notices until the value is wrong. The\n * product draws such a field itself (`custom`), or changes the schema.\n *\n * Only the dialects the two writers produce are read: draft 2020-12 and\n * draft 7, local `$ref` into `$defs` or `definitions`, nullable spelt either\n * as a type list or as a union with `null`, and a choice spelt as `enum` or\n * as a union of `const`s carrying a `title`.\n */\n\n/** A JSON Schema, or one of its parts. `true` and `false` are schemas too. */\nexport type JsonSchema = boolean | { readonly [keyword: string]: unknown }\n\n/** How a field is drawn. The data's type picks it; `x-control` in the schema\n * picks between the drawings one type has. */\nexport type SchemaControl =\n | 'text'\n | 'multiline'\n | 'secret'\n | 'email'\n | 'url'\n | 'date'\n | 'number'\n | 'integer'\n | 'boolean'\n | 'choice'\n | 'segmented'\n | 'radio'\n | 'choices'\n | 'tags'\n | 'group'\n /** The product draws it - named in `custom` when the schema was read. */\n | 'custom'\n\n/** One answer a choice offers. */\nexport interface SchemaOption {\n value: string | number | boolean\n /** `title` if the schema gives one, else the value as written. */\n label: string\n help?: string\n}\n\n/** The keywords a value is checked against, as the schema states them. */\nexport interface SchemaRules {\n minLength?: number\n maxLength?: number\n pattern?: string\n format?: string\n minimum?: number\n maximum?: number\n exclusiveMinimum?: number\n exclusiveMaximum?: number\n multipleOf?: number\n minItems?: number\n maxItems?: number\n uniqueItems?: boolean\n}\n\nexport interface SchemaField {\n /** Where the value lives in the object: `['server', 'port']`. */\n path: readonly string[]\n /** The path as one string, `server.port` - what `errors` and `custom` are\n * keyed by. */\n key: string\n control: SchemaControl\n /** `title`, else the property's own name. Never a word of this file's. */\n label: string\n help?: string\n required: boolean\n /** Empty is written as `null` rather than by leaving the key out. */\n nullable: boolean\n readOnly: boolean\n default?: unknown\n /** A value the schema allows and no person chooses - a discriminator. Not\n * drawn; `defaultsOf` writes it. */\n fixed?: { value: unknown }\n rules: SchemaRules\n /** The rules each entry of a list of tags is held to. */\n itemRules?: SchemaRules\n options?: readonly SchemaOption[]\n fields?: readonly SchemaField[]\n}\n\n/** A schema this cannot draw, by the key of the field and the reason. */\nexport class SchemaError extends Error {\n readonly key: string\n readonly reason: string\n\n constructor(key: string, reason: string) {\n super(`${key || 'the schema'}: ${reason}`)\n this.name = 'SchemaError'\n this.key = key\n this.reason = reason\n }\n}\n\ntype Node = { readonly [keyword: string]: unknown }\n\nconst RULES = [\n 'minLength',\n 'maxLength',\n 'pattern',\n 'format',\n 'minimum',\n 'maximum',\n 'exclusiveMinimum',\n 'exclusiveMaximum',\n 'multipleOf',\n 'minItems',\n 'maxItems',\n 'uniqueItems',\n] as const\n\n/** The keywords that make a schema something this file cannot draw. Each is\n * refused by name, so the message says what to change. */\nconst REFUSED = ['patternProperties', 'prefixItems', 'not', 'if', 'dependentSchemas']\n\n/** Where a drawing chosen by `x-control` is allowed: the type it redraws. */\nconst REDRAWS: Record<string, SchemaControl> = { multiline: 'text', segmented: 'choice', radio: 'choice' }\n\nconst isNode = (value: unknown): value is Node =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\nconst isNull = (node: unknown) => isNode(node) && (node.type === 'null' || node.const === null)\n\n/** The node with one keyword taken out - what stands beside a `$ref` or a\n * union, kept so it can be laid over what the keyword resolves to. */\nfunction without(node: Node, keyword: string): Node {\n const rest: Record<string, unknown> = { ...node }\n delete rest[keyword]\n return rest\n}\n\n/**\n * The fields an object schema describes, in the order its properties are\n * written - which is the order zod and serde declare them in.\n *\n * `custom` names the keys the product draws itself: they are read for their\n * label and help, and never refused.\n */\nexport function readSchema(schema: JsonSchema, custom: Iterable<string> = []): SchemaField[] {\n if (!isNode(schema)) throw new SchemaError('', 'the root is not an object schema')\n const read = new Reader(schema, new Set(custom))\n const root = read.resolve(schema, '', [])\n if (!isNode(root.properties)) throw new SchemaError('', 'the root has no properties')\n return read.fields(root, [])\n}\n\nclass Reader {\n private readonly root: Node\n private readonly custom: ReadonlySet<string>\n\n constructor(root: Node, custom: ReadonlySet<string>) {\n this.root = root\n this.custom = custom\n }\n\n /** `$ref`, a lone `allOf` and nullability peeled off, so what is left says\n * what the value is. Keywords beside a `$ref` win over the target's - in\n * 2020-12 a description at the use site describes the use. */\n resolve(node: Node, key: string, seen: readonly string[]): Node & { nullable?: boolean } {\n if (typeof node.$ref === 'string') {\n const ref = node.$ref\n if (seen.includes(ref)) throw new SchemaError(key, `\\`${ref}\\` refers to itself, and a form cannot be drawn to no end`)\n const beside = without(node, '$ref')\n return this.resolve({ ...this.pointer(ref, key), ...beside }, key, [...seen, ref])\n }\n if (Array.isArray(node.allOf)) {\n if (node.allOf.length !== 1 || !isNode(node.allOf[0])) throw new SchemaError(key, '`allOf` of several schemas is not read')\n const beside = without(node, 'allOf')\n return this.resolve({ ...node.allOf[0], ...beside }, key, seen)\n }\n for (const union of ['anyOf', 'oneOf'] as const) {\n const members = node[union]\n if (!Array.isArray(members) || !members.some(isNull)) continue\n // `null` among the members is the field being allowed to be empty, not\n // one more thing it can be: zod writes `.nullable()` this way, and\n // schemars writes `Option<T>` this way when T is a reference.\n const rest = members.filter((member) => !isNull(member))\n const beside = without(node, union)\n const inner = rest.length === 1 && isNode(rest[0]) ? { ...rest[0], ...beside } : { ...beside, [union]: rest }\n return { ...this.resolve(inner, key, seen), nullable: true }\n }\n if (Array.isArray(node.type)) {\n const types = node.type.filter((type) => type !== 'null')\n if (types.length !== 1) throw new SchemaError(key, `a value of several types (${node.type.join(', ')}) is not read`)\n return { ...node, type: types[0], nullable: node.nullable === true || types.length < node.type.length }\n }\n if (Array.isArray(node.enum) && node.enum.includes(null)) return { ...node, nullable: true }\n return node\n }\n\n /** A local JSON pointer: `#/$defs/Server`. A remote one would need a fetch\n * this file is in no position to make. */\n pointer(ref: string, key: string): Node {\n if (!ref.startsWith('#/')) throw new SchemaError(key, `\\`${ref}\\` is not a local reference`)\n let at: unknown = this.root\n for (const part of ref.slice(2).split('/')) {\n const name = decodeURIComponent(part).replace(/~1/g, '/').replace(/~0/g, '~')\n at = isNode(at) ? at[name] : undefined\n }\n if (!isNode(at)) throw new SchemaError(key, `\\`${ref}\\` points at nothing`)\n return at\n }\n\n fields(node: Node, path: readonly string[]): SchemaField[] {\n const required = Array.isArray(node.required) ? node.required : []\n return Object.entries(node.properties as Node).map(([name, child]) => {\n const at = [...path, name]\n return this.field(isNode(child) ? child : {}, at, required.includes(name))\n })\n }\n\n field(raw: Node, path: readonly string[], required: boolean): SchemaField {\n const key = path.join('.')\n const node = this.resolve(raw, key, [])\n const field: SchemaField = {\n path,\n key,\n control: 'custom',\n label: typeof node.title === 'string' ? node.title : path[path.length - 1]!,\n help: typeof node.description === 'string' ? node.description : undefined,\n required,\n nullable: node.nullable === true,\n readOnly: node.readOnly === true,\n default: node.default,\n rules: rulesOf(node, key),\n }\n if (this.custom.has(key)) return field\n if ('const' in node) return { ...field, fixed: { value: node.const } }\n const refused = REFUSED.find((keyword) => keyword in node)\n if (refused) throw new SchemaError(key, `\\`${refused}\\` is not read; draw the field with \\`custom\\``)\n\n const control = this.control(node, field)\n const redraw = node['x-control']\n if (redraw === undefined) return { ...field, ...control }\n if (typeof redraw !== 'string' || REDRAWS[redraw] !== control.control) {\n throw new SchemaError(key, `\\`x-control: ${String(redraw)}\\` does not redraw a field drawn as ${control.control}`)\n }\n return { ...field, ...control, control: redraw as SchemaControl }\n }\n\n control(node: Node, field: SchemaField): Pick<SchemaField, 'control' | 'options' | 'fields' | 'itemRules'> {\n const options = optionsOf(node, field.key)\n if (options) return { control: 'choice', options }\n switch (node.type) {\n case 'object':\n if (!isNode(node.properties)) break\n if (isNode(node.additionalProperties)) break\n return { control: 'group', fields: this.fields(node, field.path) }\n case 'array': {\n if (!isNode(node.items)) break\n const items = this.resolve(node.items, field.key, [])\n if (items.nullable) break\n const choices = optionsOf(items, field.key)\n if (choices) return { control: 'choices', options: choices }\n if (items.type === 'string' && items.format === undefined) {\n return { control: 'tags', itemRules: rulesOf(items, field.key) }\n }\n break\n }\n case 'string':\n if (node.writeOnly === true || node.format === 'password') return { control: 'secret' }\n if (node.format === 'date') return { control: 'date' }\n if (node.format === 'email') return { control: 'email' }\n if (node.format === 'uri') return { control: 'url' }\n return { control: 'text' }\n case 'number':\n case 'integer':\n return { control: node.type }\n case 'boolean':\n return { control: 'boolean' }\n }\n throw new SchemaError(field.key, `${describe(node)} is not drawn; draw the field with \\`custom\\``)\n }\n}\n\n/** The answers a choice offers: `enum`, or a union whose every member is a\n * `const` - the spelling that can carry a title per answer. `null` among them\n * is not an answer: it is the field being empty, and is read as nullable. */\nfunction optionsOf(node: Node, key: string): SchemaOption[] | undefined {\n if (Array.isArray(node.enum)) {\n return node.enum.filter((value) => value !== null).map((value) => option(value, key))\n }\n const members = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined\n if (!members || members.length === 0 || !members.every((member) => isNode(member) && 'const' in member)) {\n return undefined\n }\n return (members as Node[]).map((member) =>\n option(member.const, key, typeof member.title === 'string' ? member.title : undefined, member.description),\n )\n}\n\nfunction option(value: unknown, key: string, title?: string, help?: unknown): SchemaOption {\n if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {\n throw new SchemaError(key, `the answer ${JSON.stringify(value)} is not a string, a number or a boolean`)\n }\n return { value, label: title ?? String(value), help: typeof help === 'string' ? help : undefined }\n}\n\nfunction rulesOf(node: Node, key: string): SchemaRules {\n const rules: Record<string, unknown> = {}\n for (const name of RULES) if (node[name] !== undefined) rules[name] = node[name]\n // A pattern that does not compile is the schema's mistake, said now rather\n // than on the first keystroke.\n if (typeof rules.pattern === 'string') patternOf(rules.pattern, key)\n return rules as SchemaRules\n}\n\n/** A schema's `pattern` as a RegExp. JSON Schema patterns are ECMA-262 and\n * unanchored; `u` first, as ajv reads them, and without it for a pattern\n * written for a reader that does not. */\nexport function patternOf(pattern: string, key = ''): RegExp {\n for (const flags of ['u', '']) {\n try {\n return new RegExp(pattern, flags)\n } catch {\n // Tried without `u` next; a pattern that fails both is reported below.\n }\n }\n throw new SchemaError(key, `the pattern \\`${pattern}\\` does not compile`)\n}\n\nfunction describe(node: Node): string {\n if (node.type === 'array') return 'a list of anything but answers or plain text'\n if (node.type === 'object') return 'an object without properties, or a map'\n if (Array.isArray(node.oneOf) || Array.isArray(node.anyOf)) return 'a union of schemas'\n return node.type === undefined ? 'a value of no stated type' : `a value of type \\`${String(node.type)}\\``\n}\n\n/**\n * A value for a new record: every field's `default`, and every fixed value.\n *\n * Read once, when the record is made. Showing a default in a field whose value\n * is absent would be showing a value that is not there.\n */\nexport function defaultsOf(fields: readonly SchemaField[]): Record<string, unknown> {\n const value: Record<string, unknown> = {}\n for (const field of fields) {\n const name = field.path[field.path.length - 1]!\n if (field.fixed) value[name] = field.fixed.value\n else if (field.default !== undefined) value[name] = structuredClone(field.default)\n else if (field.control === 'group') {\n const inner = defaultsOf(field.fields ?? [])\n if (field.required || Object.keys(inner).length > 0) value[name] = inner\n }\n }\n return value\n}\n"
|
|
1923
|
+
}
|
|
1924
|
+
]
|
|
1925
|
+
},
|
|
1859
1926
|
{
|
|
1860
1927
|
"name": "scroll-area",
|
|
1861
1928
|
"type": "registry:ui",
|
|
@@ -1864,7 +1931,7 @@
|
|
|
1864
1931
|
"dependencies": [
|
|
1865
1932
|
"@base-ui/react",
|
|
1866
1933
|
"class-variance-authority",
|
|
1867
|
-
"dowel-ui@^0.
|
|
1934
|
+
"dowel-ui@^0.37.0"
|
|
1868
1935
|
],
|
|
1869
1936
|
"registryDependencies": [],
|
|
1870
1937
|
"files": [
|
|
@@ -1882,7 +1949,7 @@
|
|
|
1882
1949
|
"title": "SearchField",
|
|
1883
1950
|
"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.",
|
|
1884
1951
|
"dependencies": [
|
|
1885
|
-
"dowel-ui@^0.
|
|
1952
|
+
"dowel-ui@^0.37.0"
|
|
1886
1953
|
],
|
|
1887
1954
|
"registryDependencies": [
|
|
1888
1955
|
"https://lacodda.github.io/dowel/r/commands.json",
|
|
@@ -1905,7 +1972,7 @@
|
|
|
1905
1972
|
"description": "A settings screen is the usual case: five or six sections, each its own address so it can be linked to and the back button walks between them, listed down the left with the current one tinted. Every product draws the same column, and every product draws the active row a little differently - which is exactly the drift a shared list exists to stop.",
|
|
1906
1973
|
"dependencies": [
|
|
1907
1974
|
"@base-ui/react",
|
|
1908
|
-
"dowel-ui@^0.
|
|
1975
|
+
"dowel-ui@^0.37.0"
|
|
1909
1976
|
],
|
|
1910
1977
|
"registryDependencies": [],
|
|
1911
1978
|
"files": [
|
|
@@ -1924,7 +1991,7 @@
|
|
|
1924
1991
|
"description": "Two to five options, one of them chosen, all of them in view - a radio group in a segment's clothes.",
|
|
1925
1992
|
"dependencies": [
|
|
1926
1993
|
"@base-ui/react",
|
|
1927
|
-
"dowel-ui@^0.
|
|
1994
|
+
"dowel-ui@^0.37.0"
|
|
1928
1995
|
],
|
|
1929
1996
|
"registryDependencies": [],
|
|
1930
1997
|
"files": [
|
|
@@ -1944,7 +2011,7 @@
|
|
|
1944
2011
|
"dependencies": [
|
|
1945
2012
|
"@base-ui/react",
|
|
1946
2013
|
"class-variance-authority",
|
|
1947
|
-
"dowel-ui@^0.
|
|
2014
|
+
"dowel-ui@^0.37.0"
|
|
1948
2015
|
],
|
|
1949
2016
|
"registryDependencies": [
|
|
1950
2017
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1981,7 +2048,7 @@
|
|
|
1981
2048
|
"title": "ShortcutsDialog",
|
|
1982
2049
|
"description": "The sheet of every key the application answers to, opened by `?` - written from the list of commands rather than by hand, so it cannot promise a key that does not work.",
|
|
1983
2050
|
"dependencies": [
|
|
1984
|
-
"dowel-ui@^0.
|
|
2051
|
+
"dowel-ui@^0.37.0"
|
|
1985
2052
|
],
|
|
1986
2053
|
"registryDependencies": [
|
|
1987
2054
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -2004,7 +2071,7 @@
|
|
|
2004
2071
|
"title": "SkeletonOf",
|
|
2005
2072
|
"description": "`Skeleton` and its shapes solved half the problem: they gave a product a list, a card and a grid to reach for instead of a spinner. The half left over is the one that actually causes the jump, and it is a human one - somebody has to look at the real thing, judge how many rows it has and how tall they are, and type that in. The judgement is made once, the screen changes a month later, and the placeholder goes on promising the old shape.",
|
|
2006
2073
|
"dependencies": [
|
|
2007
|
-
"dowel-ui@^0.
|
|
2074
|
+
"dowel-ui@^0.37.0"
|
|
2008
2075
|
],
|
|
2009
2076
|
"registryDependencies": [],
|
|
2010
2077
|
"files": [
|
|
@@ -2012,7 +2079,7 @@
|
|
|
2012
2079
|
"path": "ui/skeleton-of.tsx",
|
|
2013
2080
|
"target": "@ui/skeleton-of.tsx",
|
|
2014
2081
|
"type": "registry:ui",
|
|
2015
|
-
"content": "import { useCallback, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * SkeletonOf - a placeholder that remembers the shape instead of guessing it.\n *\n * `Skeleton` and its shapes solved half the problem: they gave a product a\n * list, a card and a grid to reach for instead of a spinner. The half left\n * over is the one that actually causes the jump, and it is a human one -\n * somebody has to look at the real thing, judge how many rows it has and how\n * tall they are, and type that in. The judgement is made once, the screen\n * changes a month later, and the placeholder goes on promising the old shape.\n * The line's own calendar is the recorded case: four short lines standing in\n * for a six-row month grid, and the skeleton was itself the jump it existed to\n * prevent.\n *\n * The rule is the same one `Skeleton` is built on:\n *\n * **A skeleton of the wrong shape is worse than no skeleton.**\n *\n * What is new here is where the shape comes from. The content was on the\n * screen a moment ago; it measured itself, and nothing else knows its shape\n * better. So this keeps what was there - how many rows, how tall each one, how\n * tall the whole block - and draws that back while the next load is in flight.\n *\n * **Why measuring and not reading the source.** The obvious version of \"derive\n * the skeleton from the markup\" is to parse the component's JSX and emit\n * boxes from it. It cannot work, and the reason is worth writing down: the\n * shape on screen is not in the source. It is the source plus the data (five\n * rows or fifty), plus the viewport (one column or three), plus the theme's\n * own spacing. A generator reading `list.tsx` sees one `<li>` in a `map` and\n * knows nothing about any of that. Measuring the rendered thing sees all of it\n * at once, and it costs a `ResizeObserver` rather than a build step.\n *\n * **The first load has nothing to remember**, and that is the case a product\n * must still answer: `fallback` is what stands in until there is a measurement\n * to use. After that the fallback is never seen again on that screen.\n *\n * Everything drawn here is `aria-hidden`, like every other skeleton - the\n * loading fact belongs to the region and is said once, by whatever owns it.\n */\n\n/** What was measured: the block's own height, and the rows inside it.\n *\n * Kept as plain numbers rather than as a copy of the DOM, because that is all\n * a placeholder needs and because a detached node would pin the whole tree in\n * memory for as long as the screen lives. */\nexport interface MeasuredShape {\n /** The block's height when it was last full, in pixels. */\n height: number\n /** The height of each row inside it. Empty when nothing matched the row\n * selector - a block with no repeating part is a single box. */\n rows: number[]\n}\n\n/**\n * Measure a node: its height, and the heights of the rows inside it.\n *\n * Exported because a product occasionally wants the measurement without the\n * component - to persist it between sessions, for instance, so that even the\n * first load of a screen already knows its shape.\n *\n * Rows are found by selector rather than by walking children, because the\n * repeating part is rarely a direct child: a table's rows are inside a\n * `<tbody>`, a list's inside a `<ul>` that may itself be inside a scroller.\n */\nexport function measureShape(node: HTMLElement, rowSelector?: string): MeasuredShape {\n const height = node.getBoundingClientRect().height\n const rows = rowSelector\n ? [...node.querySelectorAll(rowSelector)].map((row) => row.getBoundingClientRect().height)\n : []\n return { height, rows }\n}\n\nexport interface SkeletonOfProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** Whether the content is on its way. While false the children are shown and\n * measured; while true the remembered shape is drawn in their place. */\n pending: boolean\n /** The real thing. Rendered and measured when not pending. */\n children: ReactNode\n /**\n * What counts as a row - `'tr'`, `'li'`, `'[data-row]'`. Given, the\n * placeholder draws one box per row at the height that row had; omitted, it\n * draws a single box the height of the whole block.\n */\n rowSelector?: string\n /**\n * What to draw before anything has been measured - the first load of a\n * screen, where there is genuinely nothing to remember. A `SkeletonList` of\n * roughly the right size is the usual answer.\n */\n fallback?: ReactNode\n /**\n * A shape measured earlier - from `measureShape`, stored by the product\n * between sessions. Used until this instance measures something itself, so\n * even a first load can be honest.\n */\n shape?: MeasuredShape\n /** The gap between drawn rows, as a Tailwind class. Matches whatever the\n * real list uses; the rows' own heights come from the measurement. */\n gapClassName?: string\n}\n\n/**\n * Shows the children, remembers their shape, and draws that shape back while\n * the next load is in flight.\n *\n * The measurement is taken in a layout effect and on every resize, not once on\n * mount: a list that gains rows, a window that narrows, a font that loads late\n * all change the shape, and a placeholder built from the first measurement\n * would be wrong in exactly the way this component exists to prevent.\n */\nexport function SkeletonOf({\n pending,\n children,\n rowSelector,\n fallback,\n shape,\n gapClassName,\n className,\n ...props\n}: SkeletonOfProps) {\n const [measured, setMeasured] = useState<MeasuredShape | undefined>(shape)\n const node = useRef<HTMLDivElement | null>(null)\n\n const remember = useCallback(() => {\n const element = node.current\n if (!element) return\n const next = measureShape(element, rowSelector)\n // A block of no height is a block that has not been laid out - a hidden\n // tab, a parent still collapsing. Remembering a zero would draw nothing\n // and claim it was the shape.\n if (next.height <= 0) return\n setMeasured((held) =>\n held && held.height === next.height && sameRows(held.rows, next.rows) ? held : next,\n )\n }, [rowSelector])\n\n useLayoutEffect(() => {\n // Only the branch that renders the content carries the ref, so while\n // pending there is nothing to measure and `remember` finds no node. That\n // is what keeps the memory from decaying: measuring the placeholder would\n // record the shape it had just drawn from memory, and each load would\n // remember a little less of what the content actually looked like.\n //\n // It is worth stating rather than relying on, because it rests on React\n // clearing a ref when the node it points at unmounts. The guard below is\n // that fact made explicit - if the component ever renders both branches,\n // this is the line that keeps the loop from closing.\n if (pending || !node.current) return\n remember()\n\n // jsdom has no ResizeObserver, and a product may run in one. The component\n // is still correct without it: it has the mount measurement, it just does\n // not follow later changes.\n if (typeof ResizeObserver === 'undefined') return\n const element = node.current\n if (!element) return\n const observer = new ResizeObserver(remember)\n observer.observe(element)\n return () => observer.disconnect()\n }, [pending, remember, children])\n\n if (!pending) {\n return (\n <div ref={node} className={className} {...props}>\n {children}\n </div>\n )\n }\n\n // Nothing measured yet, and nothing handed over: the first load of a screen\n // that did not say what it is waiting for. Saying nothing is better than\n // drawing a shape made up on the spot.\n if (!measured) return <>{fallback ?? null}</>\n\n return (\n <div aria-hidden className={cn('flex flex-col', gapClassName ?? 'gap-1', className)} {...props}>\n {measured.rows.length > 0 ? (\n measured.rows.map((height, row) => (\n <div\n key={row}\n style={{ height }}\n className=\"animate-pulse shrink-0 rounded-md bg-soft\"\n />\n ))\n ) : (\n <div style={{ height: measured.height }} className=\"animate-pulse rounded-md bg-soft\" />\n )}\n </div>\n )\n}\n\n/** Whether two row measurements are the same run of heights.\n *\n * Compared rather than replaced wholesale so that a resize that changes\n * nothing does not hand back a new object every frame - which would re-render\n * the subtree on every pixel of a window drag. */\nfunction sameRows(left: number[], right: number[]): boolean {\n return left.length === right.length && left.every((height, index) => height === right[index])\n}\n"
|
|
2082
|
+
"content": "import { useCallback, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * SkeletonOf - a placeholder that remembers the shape instead of guessing it.\n *\n * `Skeleton` and its shapes solved half the problem: they gave a product a\n * list, a card and a grid to reach for instead of a spinner. The half left\n * over is the one that actually causes the jump, and it is a human one -\n * somebody has to look at the real thing, judge how many rows it has and how\n * tall they are, and type that in. The judgement is made once, the screen\n * changes a month later, and the placeholder goes on promising the old shape.\n * The line's own calendar is the recorded case: four short lines standing in\n * for a six-row month grid, and the skeleton was itself the jump it existed to\n * prevent.\n *\n * The rule is the same one `Skeleton` is built on:\n *\n * **A skeleton of the wrong shape is worse than no skeleton.**\n *\n * What is new here is where the shape comes from. The content was on the\n * screen a moment ago; it measured itself, and nothing else knows its shape\n * better. So this keeps what was there - how many rows, how tall each one, how\n * tall the whole block - and draws that back while the next load is in flight.\n *\n * **Why measuring and not reading the source.** The obvious version of \"derive\n * the skeleton from the markup\" is to parse the component's JSX and emit\n * boxes from it. It cannot work, and the reason is worth writing down: the\n * shape on screen is not in the source. It is the source plus the data (five\n * rows or fifty), plus the viewport (one column or three), plus the theme's\n * own spacing. A generator reading `list.tsx` sees one `<li>` in a `map` and\n * knows nothing about any of that. Measuring the rendered thing sees all of it\n * at once, and it costs a `ResizeObserver` rather than a build step.\n *\n * **The first load has nothing to remember**, and that is the case a product\n * must still answer: `fallback` is what stands in until there is a measurement\n * to use. After that the fallback is never seen again on that screen.\n *\n * Everything drawn here is `aria-hidden`, like every other skeleton - the\n * loading fact belongs to the region and is said once, by whatever owns it.\n */\n\n/** What was measured: the block's own height, and the rows inside it.\n *\n * Kept as plain numbers rather than as a copy of the DOM, because that is all\n * a placeholder needs and because a detached node would pin the whole tree in\n * memory for as long as the screen lives. */\nexport interface MeasuredShape {\n /** The block's height when it was last full, in pixels. */\n height: number\n /** The height of each row inside it. Empty when nothing matched the row\n * selector - a block with no repeating part is a single box. */\n rows: number[]\n}\n\n/**\n * Measure a node: its height, and the heights of the rows inside it.\n *\n * Exported because a product occasionally wants the measurement without the\n * component - to persist it between sessions, for instance, so that even the\n * first load of a screen already knows its shape.\n *\n * Rows are found by selector rather than by walking children, because the\n * repeating part is rarely a direct child: a table's rows are inside a\n * `<tbody>`, a list's inside a `<ul>` that may itself be inside a scroller.\n */\nexport function measureShape(node: HTMLElement, rowSelector?: string): MeasuredShape {\n const height = node.getBoundingClientRect().height\n const rows = rowSelector\n ? [...node.querySelectorAll(rowSelector)].map((row) => row.getBoundingClientRect().height)\n : []\n return { height, rows }\n}\n\nexport interface SkeletonOfProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** Whether the content is on its way. While false the children are shown and\n * measured; while true the remembered shape is drawn in their place. */\n pending: boolean\n /** The real thing. Rendered and measured when not pending. */\n children: ReactNode\n /**\n * What counts as a row - `'tr'`, `'li'`, `'[data-row]'`. Given, the\n * placeholder draws one box per row at the height that row had; omitted, it\n * draws a single box the height of the whole block.\n */\n rowSelector?: string\n /**\n * What to draw before anything has been measured - the first load of a\n * screen, where there is genuinely nothing to remember. A `SkeletonList` of\n * roughly the right size is the usual answer.\n */\n fallback?: ReactNode\n /**\n * A shape measured earlier - from `measureShape`, stored by the product\n * between sessions. Used until this instance measures something itself, so\n * even a first load can be honest.\n */\n shape?: MeasuredShape\n /** The gap between drawn rows, as a Tailwind class. Matches whatever the\n * real list uses; the rows' own heights come from the measurement. */\n gapClassName?: string\n}\n\n/**\n * Shows the children, remembers their shape, and draws that shape back while\n * the next load is in flight.\n *\n * The measurement is taken in a layout effect and on every resize, not once on\n * mount: a list that gains rows, a window that narrows, a font that loads late\n * all change the shape, and a placeholder built from the first measurement\n * would be wrong in exactly the way this component exists to prevent.\n */\nexport function SkeletonOf({\n pending,\n children,\n rowSelector,\n fallback,\n shape,\n gapClassName,\n className,\n ...props\n}: SkeletonOfProps) {\n const [measured, setMeasured] = useState<MeasuredShape | undefined>(shape)\n const node = useRef<HTMLDivElement | null>(null)\n\n const remember = useCallback(() => {\n const element = node.current\n if (!element) return\n const next = measureShape(element, rowSelector)\n // A block of no height is a block that has not been laid out - a hidden\n // tab, a parent still collapsing. Remembering a zero would draw nothing\n // and claim it was the shape.\n if (next.height <= 0) return\n setMeasured((held) =>\n held && held.height === next.height && sameRows(held.rows, next.rows) ? held : next,\n )\n }, [rowSelector])\n\n useLayoutEffect(() => {\n // Only the branch that renders the content carries the ref, so while\n // pending there is nothing to measure and `remember` finds no node. That\n // is what keeps the memory from decaying: measuring the placeholder would\n // record the shape it had just drawn from memory, and each load would\n // remember a little less of what the content actually looked like.\n //\n // It is worth stating rather than relying on, because it rests on React\n // clearing a ref when the node it points at unmounts. The guard below is\n // that fact made explicit - if the component ever renders both branches,\n // this is the line that keeps the loop from closing.\n if (pending || !node.current) return\n remember()\n\n // jsdom has no ResizeObserver, and a product may run in one. The component\n // is still correct without it: it has the mount measurement, it just does\n // not follow later changes.\n if (typeof ResizeObserver === 'undefined') return\n const element = node.current\n if (!element) return\n const observer = new ResizeObserver(remember)\n observer.observe(element)\n return () => observer.disconnect()\n }, [pending, remember, children])\n\n if (!pending) {\n return (\n <div ref={node} className={className} {...props}>\n {children}\n </div>\n )\n }\n\n // Nothing measured yet, and nothing handed over: the first load of a screen\n // that did not say what it is waiting for. Saying nothing is better than\n // drawing a shape made up on the spot.\n if (!measured) return <>{fallback ?? null}</>\n\n return (\n <div aria-hidden className={cn('flex flex-col', gapClassName ?? 'gap-1', className)} {...props}>\n {measured.rows.length > 0 ? (\n measured.rows.map((height, row) => (\n <div\n key={row}\n style={{ height }}\n className=\"animate-pulse motion-reduce:animate-none shrink-0 rounded-md bg-soft\"\n />\n ))\n ) : (\n <div style={{ height: measured.height }} className=\"animate-pulse motion-reduce:animate-none rounded-md bg-soft\" />\n )}\n </div>\n )\n}\n\n/** Whether two row measurements are the same run of heights.\n *\n * Compared rather than replaced wholesale so that a resize that changes\n * nothing does not hand back a new object every frame - which would re-render\n * the subtree on every pixel of a window drag. */\nfunction sameRows(left: number[], right: number[]): boolean {\n return left.length === right.length && left.every((height, index) => height === right[index])\n}\n"
|
|
2016
2083
|
}
|
|
2017
2084
|
]
|
|
2018
2085
|
},
|
|
@@ -2022,7 +2089,7 @@
|
|
|
2022
2089
|
"title": "Skeleton",
|
|
2023
2090
|
"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.",
|
|
2024
2091
|
"dependencies": [
|
|
2025
|
-
"dowel-ui@^0.
|
|
2092
|
+
"dowel-ui@^0.37.0"
|
|
2026
2093
|
],
|
|
2027
2094
|
"registryDependencies": [],
|
|
2028
2095
|
"files": [
|
|
@@ -2030,7 +2097,7 @@
|
|
|
2030
2097
|
"path": "ui/skeleton.tsx",
|
|
2031
2098
|
"target": "@ui/skeleton.tsx",
|
|
2032
2099
|
"type": "registry:ui",
|
|
2033
|
-
"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"
|
|
2100
|
+
"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 // Still under reduced motion: a placeholder that does not breathe is\n // still the right placeholder.\n className={cn('animate-pulse motion-reduce:animate-none 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"
|
|
2034
2101
|
}
|
|
2035
2102
|
]
|
|
2036
2103
|
},
|
|
@@ -2041,7 +2108,7 @@
|
|
|
2041
2108
|
"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\".",
|
|
2042
2109
|
"dependencies": [
|
|
2043
2110
|
"@base-ui/react",
|
|
2044
|
-
"dowel-ui@^0.
|
|
2111
|
+
"dowel-ui@^0.37.0"
|
|
2045
2112
|
],
|
|
2046
2113
|
"registryDependencies": [],
|
|
2047
2114
|
"files": [
|
|
@@ -2060,7 +2127,7 @@
|
|
|
2060
2127
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
2061
2128
|
"dependencies": [
|
|
2062
2129
|
"class-variance-authority",
|
|
2063
|
-
"dowel-ui@^0.
|
|
2130
|
+
"dowel-ui@^0.37.0"
|
|
2064
2131
|
],
|
|
2065
2132
|
"registryDependencies": [],
|
|
2066
2133
|
"files": [
|
|
@@ -2079,7 +2146,7 @@
|
|
|
2079
2146
|
"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.",
|
|
2080
2147
|
"dependencies": [
|
|
2081
2148
|
"class-variance-authority",
|
|
2082
|
-
"dowel-ui@^0.
|
|
2149
|
+
"dowel-ui@^0.37.0"
|
|
2083
2150
|
],
|
|
2084
2151
|
"registryDependencies": [],
|
|
2085
2152
|
"files": [
|
|
@@ -2087,7 +2154,7 @@
|
|
|
2087
2154
|
"path": "ui/spinner.tsx",
|
|
2088
2155
|
"target": "@ui/spinner.tsx",
|
|
2089
2156
|
"type": "registry:ui",
|
|
2090
|
-
"content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Spinner.\n *\n * Something is happening and the answer has not arrived. It carries no text of\n * its own - what is loading is the product's word, not the system's - but it\n * does have to say *something* to a screen reader, or a page that is busy is\n * silently identical to a page that is empty.\n *\n * Under `prefers-reduced-motion` the theme
|
|
2157
|
+
"content": "import type { HTMLAttributes } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\n\n/*\n * Spinner.\n *\n * Something is happening and the answer has not arrived. It carries no text of\n * its own - what is loading is the product's word, not the system's - but it\n * does have to say *something* to a screen reader, or a page that is busy is\n * silently identical to a page that is empty.\n *\n * Under `prefers-reduced-motion` the ring stands still, and says so itself\n * (`motion-reduce:animate-none`) rather than leaving it to the theme's rule,\n * which would run the turn once and stop wherever it ended. That is not a\n * detail: for some readers a spinning thing is not decoration but a symptom.\n * A still ring keeps its gap and still says \"busy\" through `role`, which is\n * the part that carried the meaning all along.\n */\nexport const spinnerVariants = cva('inline-block animate-spin motion-reduce:animate-none rounded-full border-2 border-current', {\n variants: {\n size: {\n sm: 'size-3.5 border-[1.5px]',\n md: 'size-4',\n lg: 'size-6',\n },\n tone: {\n /* Follows the text it sits in. */\n current: 'text-current',\n dim: 'text-dim',\n accent: 'text-accent',\n },\n },\n defaultVariants: { size: 'md', tone: 'current' },\n})\n\nexport interface SpinnerProps\n extends HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof spinnerVariants> {\n /** What is being waited for, for a screen reader. The product's word. */\n label?: string\n}\n\nexport function Spinner({ size, tone, label, className, ...props }: SpinnerProps) {\n return (\n <span role=\"status\" aria-live=\"polite\" className={cn('inline-flex items-center gap-2', className)} {...props}>\n <span\n // The gap in the ring is what makes the rotation visible.\n className={cn(spinnerVariants({ size, tone }), 'border-r-transparent')}\n aria-hidden\n />\n {label && <span className=\"sr-only\">{label}</span>}\n </span>\n )\n}\n"
|
|
2091
2158
|
}
|
|
2092
2159
|
]
|
|
2093
2160
|
},
|
|
@@ -2097,7 +2164,7 @@
|
|
|
2097
2164
|
"title": "Splash",
|
|
2098
2165
|
"description": "A desktop product has a second or two between the window appearing and the first screen being ready - a workspace to open, a database to migrate, a plugin to start - and a blank window for that long reads as a crash. So the window shows the product instead: the mark, the name, the promise, the version, and a bar that sweeps until there is something to draw.",
|
|
2099
2166
|
"dependencies": [
|
|
2100
|
-
"dowel-ui@^0.
|
|
2167
|
+
"dowel-ui@^0.37.0"
|
|
2101
2168
|
],
|
|
2102
2169
|
"registryDependencies": [],
|
|
2103
2170
|
"files": [
|
|
@@ -2116,7 +2183,7 @@
|
|
|
2116
2183
|
"description": "The splitter primitive.",
|
|
2117
2184
|
"dependencies": [
|
|
2118
2185
|
"class-variance-authority",
|
|
2119
|
-
"dowel-ui@^0.
|
|
2186
|
+
"dowel-ui@^0.37.0"
|
|
2120
2187
|
],
|
|
2121
2188
|
"registryDependencies": [],
|
|
2122
2189
|
"files": [
|
|
@@ -2135,7 +2202,7 @@
|
|
|
2135
2202
|
"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.",
|
|
2136
2203
|
"dependencies": [
|
|
2137
2204
|
"class-variance-authority",
|
|
2138
|
-
"dowel-ui@^0.
|
|
2205
|
+
"dowel-ui@^0.37.0"
|
|
2139
2206
|
],
|
|
2140
2207
|
"registryDependencies": [],
|
|
2141
2208
|
"files": [
|
|
@@ -2154,7 +2221,7 @@
|
|
|
2154
2221
|
"description": "The smallest thing a screen can say about something's condition: a server is up, a job failed, a person is away. Every product of the line drew its own coloured circle, and every one of them drew it the same way - a `<span>` with a background - which means the condition existed for exactly the readers who could see it.",
|
|
2155
2222
|
"dependencies": [
|
|
2156
2223
|
"class-variance-authority",
|
|
2157
|
-
"dowel-ui@^0.
|
|
2224
|
+
"dowel-ui@^0.37.0"
|
|
2158
2225
|
],
|
|
2159
2226
|
"registryDependencies": [],
|
|
2160
2227
|
"files": [
|
|
@@ -2173,7 +2240,7 @@
|
|
|
2173
2240
|
"description": "The steps of a task too long for one screen, and where you stand in them: set up a workspace, import a library, configure a connection. It is a picture of progress on its own, and the top of a Wizard when the steps are a form - a product can show how far along something is without owning the form that moves it, which is why the two are separate components. Base UI has no stepper, so this one is written here rather than wrapped.",
|
|
2174
2241
|
"dependencies": [
|
|
2175
2242
|
"class-variance-authority",
|
|
2176
|
-
"dowel-ui@^0.
|
|
2243
|
+
"dowel-ui@^0.37.0"
|
|
2177
2244
|
],
|
|
2178
2245
|
"registryDependencies": [],
|
|
2179
2246
|
"files": [
|
|
@@ -2192,7 +2259,7 @@
|
|
|
2192
2259
|
"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.",
|
|
2193
2260
|
"dependencies": [
|
|
2194
2261
|
"@base-ui/react",
|
|
2195
|
-
"dowel-ui@^0.
|
|
2262
|
+
"dowel-ui@^0.37.0"
|
|
2196
2263
|
],
|
|
2197
2264
|
"registryDependencies": [],
|
|
2198
2265
|
"files": [
|
|
@@ -2227,7 +2294,7 @@
|
|
|
2227
2294
|
"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.",
|
|
2228
2295
|
"dependencies": [
|
|
2229
2296
|
"class-variance-authority",
|
|
2230
|
-
"dowel-ui@^0.
|
|
2297
|
+
"dowel-ui@^0.37.0"
|
|
2231
2298
|
],
|
|
2232
2299
|
"registryDependencies": [
|
|
2233
2300
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -2249,7 +2316,7 @@
|
|
|
2249
2316
|
"dependencies": [
|
|
2250
2317
|
"@base-ui/react",
|
|
2251
2318
|
"class-variance-authority",
|
|
2252
|
-
"dowel-ui@^0.
|
|
2319
|
+
"dowel-ui@^0.37.0"
|
|
2253
2320
|
],
|
|
2254
2321
|
"registryDependencies": [],
|
|
2255
2322
|
"files": [
|
|
@@ -2268,7 +2335,7 @@
|
|
|
2268
2335
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
2269
2336
|
"dependencies": [
|
|
2270
2337
|
"class-variance-authority",
|
|
2271
|
-
"dowel-ui@^0.
|
|
2338
|
+
"dowel-ui@^0.37.0"
|
|
2272
2339
|
],
|
|
2273
2340
|
"registryDependencies": [
|
|
2274
2341
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -2289,7 +2356,7 @@
|
|
|
2289
2356
|
"title": "Textarea",
|
|
2290
2357
|
"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.",
|
|
2291
2358
|
"dependencies": [
|
|
2292
|
-
"dowel-ui@^0.
|
|
2359
|
+
"dowel-ui@^0.37.0"
|
|
2293
2360
|
],
|
|
2294
2361
|
"registryDependencies": [
|
|
2295
2362
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2310,7 +2377,7 @@
|
|
|
2310
2377
|
"description": "The tier primitive.",
|
|
2311
2378
|
"dependencies": [
|
|
2312
2379
|
"class-variance-authority",
|
|
2313
|
-
"dowel-ui@^0.
|
|
2380
|
+
"dowel-ui@^0.37.0"
|
|
2314
2381
|
],
|
|
2315
2382
|
"registryDependencies": [],
|
|
2316
2383
|
"files": [
|
|
@@ -2328,7 +2395,7 @@
|
|
|
2328
2395
|
"title": "TimeField",
|
|
2329
2396
|
"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`.",
|
|
2330
2397
|
"dependencies": [
|
|
2331
|
-
"dowel-ui@^0.
|
|
2398
|
+
"dowel-ui@^0.37.0"
|
|
2332
2399
|
],
|
|
2333
2400
|
"registryDependencies": [
|
|
2334
2401
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2349,7 +2416,7 @@
|
|
|
2349
2416
|
"description": "What happened, in the order it happened: a release history, an audit trail, the steps a job went through. Four products of the line draw one, and all four drew it the same way - a list with a border on the left and a dot positioned over it by hand.",
|
|
2350
2417
|
"dependencies": [
|
|
2351
2418
|
"class-variance-authority",
|
|
2352
|
-
"dowel-ui@^0.
|
|
2419
|
+
"dowel-ui@^0.37.0"
|
|
2353
2420
|
],
|
|
2354
2421
|
"registryDependencies": [],
|
|
2355
2422
|
"files": [
|
|
@@ -2369,7 +2436,7 @@
|
|
|
2369
2436
|
"dependencies": [
|
|
2370
2437
|
"@base-ui/react",
|
|
2371
2438
|
"class-variance-authority",
|
|
2372
|
-
"dowel-ui@^0.
|
|
2439
|
+
"dowel-ui@^0.37.0"
|
|
2373
2440
|
],
|
|
2374
2441
|
"registryDependencies": [],
|
|
2375
2442
|
"files": [
|
|
@@ -2389,7 +2456,7 @@
|
|
|
2389
2456
|
"dependencies": [
|
|
2390
2457
|
"@base-ui/react",
|
|
2391
2458
|
"class-variance-authority",
|
|
2392
|
-
"dowel-ui@^0.
|
|
2459
|
+
"dowel-ui@^0.37.0"
|
|
2393
2460
|
],
|
|
2394
2461
|
"registryDependencies": [
|
|
2395
2462
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -2426,7 +2493,7 @@
|
|
|
2426
2493
|
"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.",
|
|
2427
2494
|
"dependencies": [
|
|
2428
2495
|
"class-variance-authority",
|
|
2429
|
-
"dowel-ui@^0.
|
|
2496
|
+
"dowel-ui@^0.37.0"
|
|
2430
2497
|
],
|
|
2431
2498
|
"registryDependencies": [
|
|
2432
2499
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -2462,7 +2529,7 @@
|
|
|
2462
2529
|
"title": "TreeView",
|
|
2463
2530
|
"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.",
|
|
2464
2531
|
"dependencies": [
|
|
2465
|
-
"dowel-ui@^0.
|
|
2532
|
+
"dowel-ui@^0.37.0"
|
|
2466
2533
|
],
|
|
2467
2534
|
"registryDependencies": [
|
|
2468
2535
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -2482,7 +2549,7 @@
|
|
|
2482
2549
|
"title": "Truncate",
|
|
2483
2550
|
"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.",
|
|
2484
2551
|
"dependencies": [
|
|
2485
|
-
"dowel-ui@^0.
|
|
2552
|
+
"dowel-ui@^0.37.0"
|
|
2486
2553
|
],
|
|
2487
2554
|
"registryDependencies": [],
|
|
2488
2555
|
"files": [
|
|
@@ -2500,7 +2567,7 @@
|
|
|
2500
2567
|
"title": "VirtualList",
|
|
2501
2568
|
"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.",
|
|
2502
2569
|
"dependencies": [
|
|
2503
|
-
"dowel-ui@^0.
|
|
2570
|
+
"dowel-ui@^0.37.0"
|
|
2504
2571
|
],
|
|
2505
2572
|
"registryDependencies": [],
|
|
2506
2573
|
"files": [
|
|
@@ -2519,7 +2586,7 @@
|
|
|
2519
2586
|
"description": "With `decorations: false` the system draws nothing, so everything it used to do is the page's: dragging the window by its title bar, double-click to maximise, the three buttons, and the edges you grab to resize. Each is small; the reason to take them on at all is that a system title bar over an application title bar costs a strip of every laptop screen for nothing.",
|
|
2520
2587
|
"dependencies": [
|
|
2521
2588
|
"@tauri-apps/api",
|
|
2522
|
-
"dowel-ui@^0.
|
|
2589
|
+
"dowel-ui@^0.37.0"
|
|
2523
2590
|
],
|
|
2524
2591
|
"registryDependencies": [],
|
|
2525
2592
|
"files": [
|
|
@@ -2537,7 +2604,7 @@
|
|
|
2537
2604
|
"title": "Wizard",
|
|
2538
2605
|
"description": "A Stepper with the current step's content under it and the Back / Next / Finish row that walks through the steps - checking each one before it lets the user past. The Stepper alone shows progress; this is the form that makes it, and a product that only needs the picture should not install the form.",
|
|
2539
2606
|
"dependencies": [
|
|
2540
|
-
"dowel-ui@^0.
|
|
2607
|
+
"dowel-ui@^0.37.0"
|
|
2541
2608
|
],
|
|
2542
2609
|
"registryDependencies": [
|
|
2543
2610
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -2558,7 +2625,7 @@
|
|
|
2558
2625
|
"title": "EmptyDashboardScreen",
|
|
2559
2626
|
"description": "A dashboard on its first day: the few things to do before it has anything to show, with the next one ready to press, and the figures it will show once it does - drawn empty, so the screen says what it is for.",
|
|
2560
2627
|
"dependencies": [
|
|
2561
|
-
"dowel-ui@^0.
|
|
2628
|
+
"dowel-ui@^0.37.0"
|
|
2562
2629
|
],
|
|
2563
2630
|
"registryDependencies": [
|
|
2564
2631
|
"https://lacodda.github.io/dowel/r/frame.json",
|
|
@@ -2604,7 +2671,7 @@
|
|
|
2604
2671
|
"title": "LoginScreen",
|
|
2605
2672
|
"description": "The page a product opens on for someone who is not signed in yet: a name, a password, a button, and the two ways it can go wrong said differently - the server refused, or the server could not be reached.",
|
|
2606
2673
|
"dependencies": [
|
|
2607
|
-
"dowel-ui@^0.
|
|
2674
|
+
"dowel-ui@^0.37.0"
|
|
2608
2675
|
],
|
|
2609
2676
|
"registryDependencies": [
|
|
2610
2677
|
"https://lacodda.github.io/dowel/r/alert.json",
|
|
@@ -2629,7 +2696,7 @@
|
|
|
2629
2696
|
"title": "SettingsScreen",
|
|
2630
2697
|
"description": "Settings in sections: a column of destinations, each named with a line of what is in it, beside the open section - and on a phone, the column first and then the section, with the way back.",
|
|
2631
2698
|
"dependencies": [
|
|
2632
|
-
"dowel-ui@^0.
|
|
2699
|
+
"dowel-ui@^0.37.0"
|
|
2633
2700
|
],
|
|
2634
2701
|
"registryDependencies": [
|
|
2635
2702
|
"https://lacodda.github.io/dowel/r/frame.json",
|