dowel-ui 0.35.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 +32 -9
- package/dist/history.json +3919 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- 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 +300 -119
- package/dist/theme.css +36 -4
- package/package.json +4 -3
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 * 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",
|
|
@@ -787,7 +787,7 @@
|
|
|
787
787
|
"path": "ui/combobox.tsx",
|
|
788
788
|
"target": "@ui/combobox.tsx",
|
|
789
789
|
"type": "registry:ui",
|
|
790
|
-
"content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('caption px-2 py-1.5', className)}\n {...props}\n />\n )\n}\n"
|
|
790
|
+
"content": "import type { Ref } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { usePopupContainer } from './layer'\nimport { fieldClasses } from './input'\nimport { selectItemVariants, selectPopupVariants } from './select'\n\n/*\n * Combobox.\n *\n * A Select you can type in. The list narrows as the query is typed, which is\n * the only difference that matters and the reason to reach for this one: a\n * Select stops being usable somewhere around thirty options, and a country\n * picker or a tag field is well past that.\n *\n * Everything the Select comment says about the native element applies here\n * too - there is no `<select>` under it, and the input is a real `<input\n * role=\"combobox\">` so autofill, spellcheck and the phone keyboard still\n * work.\n *\n * Filtering is Base UI's: give the root an `items` array and it matches the\n * query against them with `Intl.Collator`, so accents and case behave the way\n * a reader in that language expects rather than the way `toLowerCase` does.\n * `filter` replaces the comparison; `filter={null}` turns it off for a list\n * that is filtered on a server.\n *\n * The chips are Base UI's too - Chips, Chip, ChipRemove - and that is worth\n * saying because inventing them is the obvious move and it goes wrong in one\n * specific way: hand-made chips end up as `<div>`s with an X that only a\n * pointer can reach, and the multi-select becomes keyboard-inaccessible at\n * exactly the point where it holds the most state. Base UI's are focusable,\n * walk with the arrows, and delete with Backspace.\n *\n * `Empty` renders only when nothing matched, and announces itself politely.\n * Its element stays mounted for that announcement to work, so it must not be\n * hidden with `display: none` or removed conditionally - which is why it is a\n * component here rather than a `{items.length === 0 && …}` in the product.\n */\n\n/* Two bases, chosen by `bare`, rather than one base and an override.\n *\n * Inside `ComboboxChips` the container is the field, so the input has no\n * border, no background and no focus ring of its own - a bordered box inside a\n * bordered box reads as two controls, and two focus rings appear as one thick\n * one. The obvious way to write that is `fieldClasses` plus a few `-none`\n * classes, and it does not work: `tailwind-merge` does not treat\n * `focus-visible:outline-none` as conflicting with\n * `focus-visible:outline-2 … outline-accent`, so both survive and the later\n * one in the stylesheet wins. The same trap took `w-full` versus `w-auto`\n * earlier in this file.\n *\n * So the variant picks which set applies instead of trying to subtract from\n * one - nothing is left to a merge that has no opinion. */\nexport const comboboxInputVariants = cva('', {\n variants: {\n size: {\n sm: 'h-control-sm text-xs',\n md: 'h-control',\n lg: 'h-control-lg text-base',\n },\n bare: {\n true: 'h-7 w-auto min-w-24 flex-1 bg-transparent px-1 text-sm pointer-coarse:text-lg text-text placeholder:text-faint outline-none',\n false: fieldClasses,\n },\n },\n defaultVariants: { size: 'md', bare: false },\n})\n\n/** The list, and a row in it, are Select's - imported rather than copied.\n *\n * The two popups are the same object seen twice: a dropdown of options, one\n * of which can be chosen. A reader who uses both on one screen should not be\n * able to tell which is which until they type. Two `cva` calls that started\n * identical do not stay that way - one gets the padding fix - and then the\n * form has two dropdowns that are almost the same. */\nexport const comboboxPopupVariants = selectPopupVariants\nexport const comboboxItemVariants = selectItemVariants\n\n/** The root. `items` is what gets filtered; `multiple` turns the value into an\n * array and makes the chips meaningful. */\nexport const Combobox = Base.Root\n\n/** The wrapper for an input with something beside it - a clear button, an\n * icon, the chips. */\nexport const ComboboxInputGroup = Base.InputGroup\n\n/** The button that opens the list without typing, for a reader who wants to\n * see everything there is. */\nexport const ComboboxTrigger = Base.Trigger\n\n/** The chevron. Decorative. */\nexport const ComboboxIcon = Base.Icon\n\n/** A labelled group of rows. */\nexport const ComboboxGroup = Base.Group\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A `List` is the listbox and there is one per combobox, so a grouped list is\n * a `List` over the groups with a `Collection` inside each - not a `List`\n * inside a `List`. Mapping by hand instead works, but the component then has\n * to be told how to match an item to a value, which is a second place for that\n * knowledge to live. */\nexport const ComboboxCollection = Base.Collection\n\n/** The tick, drawn only on a chosen row. */\nexport const ComboboxItemIndicator = Base.ItemIndicator\n\n/** A polite live region for the state of an asynchronous list. Stays mounted,\n * like `Empty`, so the announcement actually fires. */\nexport const ComboboxStatus = Base.Status\n\n/** The container the chips sit in. Its children are plain nodes, not a render\n * function - the chosen values are mapped by `ComboboxValue` inside it.\n *\n * It wears the field's clothes and lays the chips out in a row that wraps,\n * which is the whole difference between a control and a list: unstyled, the\n * chips stack one per line and the box grows into a column of pills with the\n * input stranded underneath. The input sits on the same line as the last\n * chip and takes the rest of the width, so a half-filled field still looks\n * like a field. */\nexport function ComboboxChips({\n ref,\n className,\n ...props\n}: Base.Chips.Props & { ref?: Ref<HTMLDivElement> }) {\n return (\n <Base.Chips\n // Taken out of `...props` and passed on deliberately: a product needs a\n // handle on this box to anchor the list to it, because the input inside\n // is only as wide as what has been typed.\n ref={ref}\n className={cn(\n fieldClasses,\n 'flex min-h-9 flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The current value, as a render function of it. This is what turns a\n * `multiple` value into one chip per entry. */\nexport const ComboboxValue = Base.Value\n\n/** `size` is taken from the native `<input size>` - a width in characters,\n * which nothing here wants - and given to the variant instead. */\nexport interface ComboboxInputProps\n extends Omit<Base.Input.Props, 'size'>,\n VariantProps<typeof comboboxInputVariants> {}\n\n/** Where the query is typed. A real `<input role=\"combobox\">`.\n *\n * Inside `ComboboxChips` it drops its own border and background: the\n * container is the field there, and a bordered input inside a bordered box\n * reads as two controls. */\nexport function ComboboxInput({ size, bare, className, ...props }: ComboboxInputProps) {\n // `bare` is pulled out and handed to `cva`. Left in `...props` it would be\n // spread onto the `<input>` as an unknown attribute and change nothing -\n // which is exactly what it did: the variant existed, the prop was passed,\n // and the class list came out without a trace of either.\n return <Base.Input className={cn(comboboxInputVariants({ size, bare }), className)} {...props} />\n}\n\nconst iconButtonClasses = cn(\n 'rounded-sm p-1 text-faint transition-colors hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-accent',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n)\n\n/** Empties the value. Base UI hides it while there is nothing to clear. */\nexport function ComboboxClear({ className, ...props }: Base.Clear.Props) {\n return <Base.Clear className={cn(iconButtonClasses, className)} {...props} />\n}\n\nexport interface ComboboxPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof comboboxPopupVariants> {\n /** Preferred side of the input. Base UI flips it when it does not fit. */\n side?: Base.Positioner.Props['side']\n /** Alignment along that side. */\n align?: Base.Positioner.Props['align']\n /** Distance from the input, in pixels. */\n sideOffset?: Base.Positioner.Props['sideOffset']\n /**\n * What to line the list up with. Defaults to the input that owns it.\n *\n * Pass the `ComboboxChips` box when there is one: with chips, the input is\n * only as wide as what has been typed - an empty one measured 214px inside\n * a 288px field - so a list anchored to it hangs short of the box a reader\n * sees. Base UI publishes the anchor's width as `--anchor-width`, which is\n * how the mismatch is visible from outside.\n */\n anchor?: Base.Positioner.Props['anchor']\n /** Where to portal to. Defaults to the raised host of the overlay this is\n * opened inside (`layer.tsx`), and to the document body when there is none -\n * either way not the element it was opened from, whose `overflow` would clip\n * it. Pass an element to put it somewhere else, such as a container being\n * screenshotted. */\n container?: Base.Portal.Props['container']\n}\n\n/** The list. Portalled and positioned against the input, or the given anchor. */\nexport function ComboboxPopup({\n size,\n side,\n align,\n sideOffset = 4,\n anchor,\n container,\n className,\n children,\n ...props\n}: ComboboxPopupProps) {\n // Inside an overlay, the overlay's raised host rather than the body - or\n // this popup draws under the dialog, drawer or popover that opened it. See\n // `layer.tsx`. Outside every overlay the hook gives `undefined`: the body.\n const host = usePopupContainer()\n\n return (\n <Base.Portal container={container ?? host}>\n <Base.Positioner\n side={side}\n align={align}\n sideOffset={sideOffset}\n anchor={anchor}\n className=\"[z-index:var(--z-menu)]\"\n >\n <Base.Popup className={cn(comboboxPopupVariants({ size }), className)} {...props}>\n {children}\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\n/** The rows, as a list. Undressed: it is a wrapper, and the popup around it\n * already carries the border and the padding. */\nexport const ComboboxList = Base.List\n\n/** A row. */\nexport function ComboboxItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** What is shown when nothing matched. The words are the product's. */\nexport function ComboboxEmpty({ className, ...props }: Base.Empty.Props) {\n return <Base.Empty className={cn('px-2 py-3 text-center text-sm text-faint', className)} {...props} />\n}\n\n/** One chosen value, in a multiple combobox. Focusable, so it can be reached\n * and removed without a pointer. */\nexport function ComboboxChip({ className, ...props }: Base.Chip.Props) {\n return (\n <Base.Chip\n className={cn(\n 'flex items-center gap-1 rounded-sm bg-soft px-1.5 py-0.5 text-xs text-text',\n 'outline-none data-[highlighted]:bg-accent-soft data-[highlighted]:text-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\n/** The X on a chip. A real button, which is what makes Backspace and Enter\n * both work on it. */\nexport function ComboboxChipRemove({ className, ...props }: Base.ChipRemove.Props) {\n return <Base.ChipRemove className={cn(iconButtonClasses, 'p-0', className)} {...props} />\n}\n\n/** The caption above a group. */\nexport function ComboboxGroupLabel({ className, ...props }: Base.GroupLabel.Props) {\n return (\n <Base.GroupLabel\n className={cn('caption px-2 py-1.5', className)}\n {...props}\n />\n )\n}\n"
|
|
791
791
|
}
|
|
792
792
|
]
|
|
793
793
|
},
|
|
@@ -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",
|
|
@@ -811,7 +811,7 @@
|
|
|
811
811
|
"path": "ui/command-palette.tsx",
|
|
812
812
|
"target": "@ui/command-palette.tsx",
|
|
813
813
|
"type": "registry:ui",
|
|
814
|
-
"content": "import { useRef, type ReactNode } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\nimport { LayerProvider } from './layer'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n *\n * The commands themselves come from `commands`: the list the keyboard and the\n * sheet of shortcuts read too, so a row's key hint is the key that works.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n md: 'max-h-96',\n lg: 'max-h-128',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ.\n *\n * They did differ, for as long as this was a bare re-export: the comment said\n * \"the same clothes\" and the component wore none, so the rows inherited the\n * popup's 16px and stood a third taller than every other list in the set. A\n * live run caught it - the palette looked like a different product. */\nexport function CommandPaletteItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** The list. Takes a render function over the filtered items. */\nexport function CommandPaletteList({ className, ...props }: Base.List.Props) {\n return <Base.List className={cn('overflow-y-auto p-1', className)} {...props} />\n}\n\n/** Shown when nothing matches. The words are the product's.\n *\n * Base UI keeps it mounted so the announcement fires, which means its padding\n * is spent whether or not it has anything to say - and a palette with six\n * results had a 48px hole under the field. It collapses when empty instead. */\nexport function CommandPaletteEmpty({ className, ...props }: Base.Empty.Props) {\n return (\n <Base.Empty\n className={cn('px-2 py-3 text-center text-sm text-faint empty:hidden empty:p-0', className)}\n {...props}\n />\n )\n}\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n /* The anchor is a point at the top of the viewport, given explicitly.\n *\n * A positioner places a popup against an anchor and hides itself with\n * an inline `opacity: 0` until it has measured one. A palette has no\n * trigger to point at - it is opened by a keystroke - so without this\n * the measure never resolves: the popup sits in the DOM at the right\n * size, fully transparent, rendering nothing and reporting no error.\n * Found by reading the computed style off the positioner rather than\n * the popup, which was opaque the whole time.\n *\n * A zero-height rectangle a fifth of the way down puts the palette\n * where the eye already is rather than dead centre. */\n anchor={{\n getBoundingClientRect: () => {\n const width = typeof window === 'undefined' ? 0 : window.innerWidth\n const top = typeof window === 'undefined' ? 0 : window.innerHeight * 0.18\n return new DOMRect(width / 2, top, 0, 0)\n },\n }}\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {/* A tooltip or a menu opened from a result rides above the palette,\n * which stands above every modal. See `layer.tsx`. */}\n <LayerProvider above=\"palette\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `Escape`. Decorative. */\n hint?: string\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n 'h-11 w-full bg-transparent text-sm text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
814
|
+
"content": "import { useRef, type ReactNode } from 'react'\nimport { Combobox as Base } from '@base-ui/react/combobox'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { comboboxItemVariants } from './combobox'\nimport { Kbd } from './kbd'\nimport { LayerProvider } from './layer'\n\n/*\n * CommandPalette.\n *\n * One box that finds anything: the shortcut opens it, typing narrows a list,\n * Enter runs what is highlighted.\n *\n * It is a Combobox rather than a Dialog with a field in it, and that is Base\n * UI's own arrangement rather than a shortcut taken here: put the input\n * *inside* the popup and the popup becomes `role=\"dialog\"` on its own, with\n * the input still announced as the combobox that owns the list. The filtering,\n * the highlight, the arrow keys and the type-ahead are the ones Combobox\n * already has - there is no second implementation of any of it.\n *\n * What is left for the product is everything that makes a palette that\n * product's: what the items are, how they are grouped, what running one does.\n * `items` is deliberately `unknown[]` - a palette lists commands, works,\n * settings and recent files in the same box, and a type that admitted only\n * strings would push every product into the same stringly-typed workaround.\n *\n * The commands themselves come from `commands`: the list the keyboard and the\n * sheet of shortcuts read too, so a row's key hint is the key that works.\n */\n\nexport const commandPalettePopupVariants = cva(\n [\n 'flex w-[min(36rem,calc(100vw-2rem))] flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n '[transition:opacity_var(--duration-quick)_var(--ease-out),transform_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n md: 'max-h-96',\n lg: 'max-h-128',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled by `open`/`onOpenChange`, because what opens a palette\n * is a shortcut somewhere else in the application. */\nexport const CommandPalette = Base.Root\n\n/** A row. The same clothes as a Combobox row, on purpose: a palette is a list\n * of choices, and two lists of choices in one product should not differ.\n *\n * They did differ, for as long as this was a bare re-export: the comment said\n * \"the same clothes\" and the component wore none, so the rows inherited the\n * popup's 16px and stood a third taller than every other list in the set. A\n * live run caught it - the palette looked like a different product. */\nexport function CommandPaletteItem({ className, ...props }: Base.Item.Props) {\n return <Base.Item className={cn(comboboxItemVariants(), className)} {...props} />\n}\n\n/** The list. Takes a render function over the filtered items. */\nexport function CommandPaletteList({ className, ...props }: Base.List.Props) {\n return <Base.List className={cn('overflow-y-auto p-1', className)} {...props} />\n}\n\n/** Shown when nothing matches. The words are the product's.\n *\n * Base UI keeps it mounted so the announcement fires, which means its padding\n * is spent whether or not it has anything to say - and a palette with six\n * results had a 48px hole under the field. It collapses when empty instead. */\nexport function CommandPaletteEmpty({ className, ...props }: Base.Empty.Props) {\n return (\n <Base.Empty\n className={cn('px-2 py-3 text-center text-sm text-faint empty:hidden empty:p-0', className)}\n {...props}\n />\n )\n}\n\n/** A labelled group, for a palette that lists more than one kind of thing. */\nexport const CommandPaletteGroup = Base.Group\n\n/** The caption above a group. */\nexport const CommandPaletteGroupLabel = Base.GroupLabel\n\n/** The rows of one group, as a render function over that group's items.\n *\n * A palette that lists works, versions and notes together is a `List` over the\n * groups with a `Collection` inside each. Mapping a group's rows by hand also\n * works, but then the palette has to be told how to match an item to a value -\n * and for rows fetched fresh from a server, identity comparison never does. */\nexport const CommandPaletteCollection = Base.Collection\n\nexport interface CommandPalettePopupProps\n extends Omit<Base.Popup.Props, 'aria-label'>,\n VariantProps<typeof commandPalettePopupVariants> {\n /**\n * What the palette is called, for a screen reader. Required, and required\n * for a reason particular to this component: the popup is a dialog, and a\n * dialog is named by its own visible title - which a palette does not have,\n * because the field is the first thing in it.\n *\n * So the name has to come from outside, it has to be the product's word, and\n * nothing else can supply it. A palette without one is announced as \"dialog\"\n * and nothing more; making the prop required is what stops that shipping.\n */\n 'aria-label': string\n /** Where to portal to. Defaults to the document body. */\n container?: Base.Portal.Props['container']\n}\n\n/** The palette itself: a dim over the page, and the box in the upper third of\n * it - where the eye already is, rather than dead centre. */\nexport function CommandPalettePopup({\n size,\n container,\n className,\n children,\n ...props\n}: CommandPalettePopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/55 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-quick)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n )}\n />\n <Base.Positioner\n className=\"[z-index:var(--z-palette)]\"\n /* The anchor is a point at the top of the viewport, given explicitly.\n *\n * A positioner places a popup against an anchor and hides itself with\n * an inline `opacity: 0` until it has measured one. A palette has no\n * trigger to point at - it is opened by a keystroke - so without this\n * the measure never resolves: the popup sits in the DOM at the right\n * size, fully transparent, rendering nothing and reporting no error.\n * Found by reading the computed style off the positioner rather than\n * the popup, which was opaque the whole time.\n *\n * A zero-height rectangle a fifth of the way down puts the palette\n * where the eye already is rather than dead centre. */\n anchor={{\n getBoundingClientRect: () => {\n const width = typeof window === 'undefined' ? 0 : window.innerWidth\n const top = typeof window === 'undefined' ? 0 : window.innerHeight * 0.18\n return new DOMRect(width / 2, top, 0, 0)\n },\n }}\n positionMethod=\"fixed\"\n side=\"bottom\"\n align=\"center\"\n sideOffset={0}\n alignOffset={0}\n >\n <Base.Popup\n className={cn(commandPalettePopupVariants({ size }), className)}\n {...props}\n >\n {/* A tooltip or a menu opened from a result rides above the palette,\n * which stands above every modal. See `layer.tsx`. */}\n <LayerProvider above=\"palette\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Positioner>\n </Base.Portal>\n )\n}\n\nexport interface CommandPaletteInputProps extends Base.Input.Props {\n /** Shown at the right of the field, as `Escape`. Decorative. */\n hint?: string\n}\n\n/** The field. Sits inside the popup, which is what makes the popup a dialog\n * and the field its combobox. */\nexport function CommandPaletteInput({ hint, className, ...props }: CommandPaletteInputProps) {\n return (\n <div className=\"flex items-center gap-2 border-b border-line px-3\">\n <MagnifierIcon />\n <Base.Input\n className={cn(\n // 16px under a finger, for the reason `fieldClasses` gives.\n 'h-11 w-full bg-transparent text-sm pointer-coarse:text-lg text-text placeholder:text-faint',\n 'focus-visible:outline-none',\n className,\n )}\n {...props}\n />\n {hint && <Kbd keys={hint} aria-hidden className=\"shrink-0\" />}\n </div>\n )\n}\n\n/** The row's own layout: an icon, what it is, and where it lives. */\nexport function CommandPaletteRow({\n icon,\n hint,\n className,\n children,\n ...props\n}: {\n icon?: ReactNode\n hint?: ReactNode\n className?: string\n children: ReactNode\n} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {\n return (\n <div className={cn('flex w-full items-center gap-2.5', className)} {...props}>\n {icon}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {hint !== undefined && <span className=\"shrink-0 text-2xs text-faint\">{hint}</span>}\n </div>\n )\n}\n\nexport { comboboxItemVariants as commandPaletteItemVariants }\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"15\"\n height=\"15\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"shrink-0 text-faint\"\n >\n <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
815
815
|
}
|
|
816
816
|
]
|
|
817
817
|
},
|
|
@@ -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",
|
|
@@ -1194,6 +1194,27 @@
|
|
|
1194
1194
|
}
|
|
1195
1195
|
]
|
|
1196
1196
|
},
|
|
1197
|
+
{
|
|
1198
|
+
"name": "frame",
|
|
1199
|
+
"type": "registry:ui",
|
|
1200
|
+
"title": "Frame",
|
|
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
|
+
"dependencies": [
|
|
1203
|
+
"dowel-ui@^0.37.0"
|
|
1204
|
+
],
|
|
1205
|
+
"registryDependencies": [
|
|
1206
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
1207
|
+
"https://lacodda.github.io/dowel/r/scroll-area.json"
|
|
1208
|
+
],
|
|
1209
|
+
"files": [
|
|
1210
|
+
{
|
|
1211
|
+
"path": "ui/frame.tsx",
|
|
1212
|
+
"target": "@ui/frame.tsx",
|
|
1213
|
+
"type": "registry:ui",
|
|
1214
|
+
"content": "import { useEffect, useRef, type HTMLAttributes, type MouseEventHandler, type ReactNode, type Ref, type RefObject } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { ScrollArea } from './scroll-area'\n\n/*\n * Frame, Pane and ListDetail.\n *\n * The geometry of a screen whose bottom edge is the window's: a column that\n * hands its height down and scrolls nowhere itself, a panel whose bars stand\n * while its body scrolls between them, and a list beside what is open in it.\n *\n * Harvested from kilna, where it is the frame every screen and every tab of a\n * card is laid out in. It exists there because the obvious way to build a\n * screen - a column that scrolls - is wrong in three ways at once, and an\n * audit found eleven screens built that way after a release had said none\n * were: the head went up with the content, a table's sticky header had\n * nothing to stick to, and a field at the foot of a tab drifted off the\n * bottom of the window with everything above it.\n *\n * So the column never scrolls. Only a part of the body does:\n *\n * - `Frame` - a screen's column. A head that stands, a body that takes the\n * rest, sometimes a foot that stands too.\n * - `Pane` - a panel with a bar at the top, a body that scrolls, and a bar\n * at the foot: an open note, a version being read, a list with its search.\n * - `ListDetail` - a list beside what is open in it. kilna drew six such\n * screens with five widths and three gaps; there are two widths here, a\n * list's and the rail's (`--spacing-list`, `--spacing-rail`), and one gap.\n *\n * The gap is ten pixels between the blocks of a screen, whichever of these\n * holds them - the mockup's, and kilna's on every screen.\n *\n * What kilna never needed is a narrow window: it is a desktop product with a\n * floor on its width. The products on the web have phones, and a list beside\n * its detail at 390px is two columns too narrow to read. Below 672px of its\n * own width - a container query, so a rail beside it counts - ListDetail\n * shows one of the two: the list until something is open, then the open\n * thing alone, with `ListDetailBack` in its head to return. That is how a\n * list and its detail work at that width everywhere else, from mail to\n * settings, and it is decided here once rather than by each screen - as is\n * where the focus goes when the two trade places (`useListDetailTurn`).\n */\n\nexport interface FrameProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** What stands above the body: a title, the filters, the screen's actions. */\n head?: ReactNode\n /** What stands under it: a composer, a status line. */\n foot?: ReactNode\n children: ReactNode\n}\n\n/** A screen's column. Hands its height to the body and scrolls nowhere. */\nexport function Frame({ head, foot, children, className, ...props }: FrameProps) {\n return (\n <div data-frame className={cn('flex min-h-0 min-w-0 flex-1 flex-col gap-2.5', className)} {...props}>\n {head === undefined ? null : <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{head}</div>}\n {/* `min-h-0` and `min-w-0`: a flex child will not shrink below its\n content without them, and a body that does not shrink pushes the\n foot off the bottom of the window - the defect this exists for. */}\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n {foot === undefined ? null : <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{foot}</div>}\n </div>\n )\n}\n\nexport interface PaneProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {\n /** What the body holds: the scroll region's name, which it needs because\n * it becomes a tab stop when it overflows - \"Works\", \"Version 3\". */\n label: string\n /** The bar across the top: a title and what acts on the open thing. */\n head?: ReactNode\n /** The bar across the foot: a status, tags, a button that adds a row. */\n foot?: ReactNode\n children: ReactNode\n /** The body's padding. A list's rows pad themselves; prose needs room. */\n bodyClassName?: string\n /** The scrolling element, for a caller that reads or sets the position. */\n viewportRef?: Ref<HTMLDivElement>\n}\n\n/** A panel whose bars stand and whose body scrolls between them. */\nexport function Pane({ label, head, foot, children, className, bodyClassName, viewportRef, ...props }: PaneProps) {\n return (\n <section\n data-pane\n className={cn(\n 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-line bg-raise',\n className,\n )}\n {...props}\n >\n {head === undefined ? null : (\n <div className=\"flex shrink-0 flex-wrap items-center gap-2 border-b border-line px-3 py-2\">{head}</div>\n )}\n {/* `vertical`: a pane scrolls down, not across. Base UI sizes its\n content to `fit-content`, and without this a row that should\n truncate to the column widens it instead - kilna's queue ran a\n hundred pixels past a 264px column before it held the axis. */}\n <ScrollArea label={label} orientation=\"vertical\" viewportRef={viewportRef} className=\"flex-1\">\n {/* The content's own box, inside the one Base UI measures: the\n viewport's single child is Base UI's, so padding set on the\n viewport would reach nothing. */}\n <div className={bodyClassName}>{children}</div>\n </ScrollArea>\n {foot === undefined ? null : (\n <div className=\"flex shrink-0 flex-wrap items-center gap-2 border-t border-line px-3 py-2\">{foot}</div>\n )}\n </section>\n )\n}\n\nexport interface ListDetailProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n /** The list, usually a `Pane` of `RowButton`s. */\n list: ReactNode\n /** What is open in it, or what says nothing is. */\n detail: ReactNode\n /**\n * Whether something in the list is open. Where there is room for both it\n * changes nothing; where there is not, it decides which of the two is\n * shown. Required, because a screen that forgot it would show a phone the\n * list forever and the detail never.\n */\n open: boolean\n /**\n * `list` for rows that carry a title and a line under it - versions,\n * works, notes, a queue. `rail` for a column of destinations, the width of\n * the app's own rail - the sections of the settings.\n */\n width?: 'list' | 'rail'\n /** Which side the list stands on. A queue read against a month stands\n * after it. */\n side?: 'start' | 'end'\n}\n\n/** A list beside what is open in it, each scrolling on its own - and one at\n * a time where there is no room for both. */\nexport function ListDetail({\n list,\n detail,\n open,\n width = 'list',\n side = 'start',\n className,\n ...props\n}: ListDetailProps) {\n const column = (\n <div\n data-list\n className={cn(\n 'flex min-h-0 shrink-0 flex-col',\n width === 'list' ? 'w-list' : 'w-rail',\n // Too narrow for both: the column takes the width while nothing is\n // open, and steps aside once something is.\n '@max-2xl/list-detail:w-full',\n open && '@max-2xl/list-detail:hidden',\n )}\n >\n {list}\n </div>\n )\n const opened = (\n <div\n data-detail\n className={cn('flex min-h-0 min-w-0 flex-1 flex-col', !open && '@max-2xl/list-detail:hidden')}\n >\n {detail}\n </div>\n )\n\n return (\n <div\n data-list-detail\n data-open={open ? '' : undefined}\n // The container the two columns measure: its own width, not the\n // window's, so a rail beside it is counted.\n className={cn('@container/list-detail flex min-h-0 min-w-0 flex-1 gap-2.5', className)}\n {...props}\n >\n {side === 'start' ? column : opened}\n {side === 'start' ? opened : column}\n </div>\n )\n}\n\n/** Whether an element is on screen: false once its column has stepped aside. */\nfunction shown(element: Element | null | undefined): element is HTMLElement {\n if (!(element instanceof HTMLElement) || !element.isConnected) return false\n // `checkVisibility` is the browser's own answer, a hidden ancestor\n // included. Where it is missing, a laid-out element has boxes.\n return typeof element.checkVisibility === 'function'\n ? element.checkVisibility()\n : element.getClientRects().length > 0\n}\n\nexport interface ListDetailTurnOptions {\n /** The key of what is open, or null. */\n selected: string | null\n /** The open thing's heading - where the focus goes once the list has\n * stepped aside. Give it `tabIndex={-1}`, or a browser will not focus it. */\n title: RefObject<HTMLElement | null>\n /** The row of a key, for the way back. */\n rowOf: (key: string) => HTMLElement | null\n /** Where the focus goes back to when the row that was open is gone - the\n * list's search field, say. */\n fallback?: RefObject<HTMLElement | null>\n}\n\n/**\n * Where the focus goes when the list and the open thing trade places.\n *\n * Beside each other nothing moves: the focus stays on the row that was\n * pressed, which is still there. Where only one fits, the pressed row has\n * just been hidden, and a browser drops the focus of a hidden element on the\n * floor - the next Tab starts from the top of the page, and a screen reader\n * says nothing at all. So it is handed to what took the row's place: the open\n * thing's heading, and on the way back the row that was open.\n *\n * Returns `turn`, to call with the key the reader asked for - before telling\n * the product. Only a turn the reader made moves the focus: a selection that\n * arrived from the address bar leaves it wherever the reader put it.\n */\nexport function useListDetailTurn({ selected, title, rowOf, fallback }: ListDetailTurnOptions) {\n const turned = useRef(false)\n // What was open before going back, so the reader lands on the row they\n // left rather than at the top of the list.\n const left = useRef<string | null>(null)\n\n useEffect(() => {\n if (!turned.current) return\n turned.current = false\n const focused = document.activeElement\n if (focused && focused !== document.body && shown(focused)) return\n if (selected !== null) {\n if (shown(title.current)) title.current.focus()\n return\n }\n const back = left.current === null ? null : rowOf(left.current)\n if (shown(back)) back.focus()\n else if (shown(fallback?.current)) fallback.current.focus()\n })\n\n return (key: string | null) => {\n turned.current = true\n if (key === null) left.current = selected\n }\n}\n\nexport interface ListDetailBackProps {\n /** What it says: \"All works\", \"Settings\". The product's word, shown\n * beside the arrow. */\n label: ReactNode\n onClick: MouseEventHandler<HTMLButtonElement>\n className?: string\n}\n\n/**\n * The way back to the list, for the width at which the list is not on\n * screen. Drawn only there: beside the list it would be a button that\n * returns to where the reader already is.\n */\nexport function ListDetailBack({ label, onClick, className }: ListDetailBackProps) {\n return (\n <Button variant=\"icon\" size=\"sm\" onClick={onClick} className={cn('@2xl/list-detail:hidden', className)}>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" aria-hidden>\n <path d=\"M10 3.5L5.5 8l4.5 4.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n {label}\n </Button>\n )\n}\n"
|
|
1215
|
+
}
|
|
1216
|
+
]
|
|
1217
|
+
},
|
|
1197
1218
|
{
|
|
1198
1219
|
"name": "image",
|
|
1199
1220
|
"type": "registry:ui",
|
|
@@ -1201,7 +1222,7 @@
|
|
|
1201
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`.",
|
|
1202
1223
|
"dependencies": [
|
|
1203
1224
|
"class-variance-authority",
|
|
1204
|
-
"dowel-ui@^0.
|
|
1225
|
+
"dowel-ui@^0.37.0"
|
|
1205
1226
|
],
|
|
1206
1227
|
"registryDependencies": [],
|
|
1207
1228
|
"files": [
|
|
@@ -1209,7 +1230,7 @@
|
|
|
1209
1230
|
"path": "ui/image.tsx",
|
|
1210
1231
|
"target": "@ui/image.tsx",
|
|
1211
1232
|
"type": "registry:ui",
|
|
1212
|
-
"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"
|
|
1213
1234
|
}
|
|
1214
1235
|
]
|
|
1215
1236
|
},
|
|
@@ -1219,7 +1240,7 @@
|
|
|
1219
1240
|
"title": "InlineField",
|
|
1220
1241
|
"description": "A value that is edited where it is shown: click it, type, Enter or leave to keep, Escape to take it back.",
|
|
1221
1242
|
"dependencies": [
|
|
1222
|
-
"dowel-ui@^0.
|
|
1243
|
+
"dowel-ui@^0.37.0"
|
|
1223
1244
|
],
|
|
1224
1245
|
"registryDependencies": [
|
|
1225
1246
|
"https://lacodda.github.io/dowel/r/field-draft.json"
|
|
@@ -1229,7 +1250,7 @@
|
|
|
1229
1250
|
"path": "ui/inline-field.tsx",
|
|
1230
1251
|
"target": "@ui/inline-field.tsx",
|
|
1231
1252
|
"type": "registry:ui",
|
|
1232
|
-
"content": "import { useId, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { useFieldDraft } from './field-draft'\n\n/*\n * InlineField.\n *\n * A value that is edited where it is shown: click it, type, Enter or leave to\n * keep, Escape to take it back.\n *\n * A board of fields - the numbers on a work's overview, the cells of a\n * storyboard - is read far more often than it is edited, so every value there\n * sits as text until it is touched. A modal to change one BPM is a lost\n * context and a click too many, and a column of always-on boxes is a form\n * nobody asked to fill in. kilna had nine of these editors, each with its own\n * idea of what Enter and Escape mean; this is one idea, and its rules are\n * FieldDraft's.\n *\n * **It is always an `<input>`.** Not text that swaps to an input on click: a\n * swap loses the caret position, steals a render, and leaves the value\n * unreachable by Tab. The box is simply undressed until it is hovered or\n * focused - the mockup's `.inl .v`: a border appears under the pointer, the\n * accent when you are in it.\n *\n * **What a value is, is a codec.** `parse` reads what was typed and `format`\n * spells the stored value, so the box can never show one thing and store\n * another. Text needs neither. `numberCodec` and `timecodeCodec` are the two\n * the line reached for; a product's own - a key signature, a tempo range - is\n * two functions. `parse` answers `null` for an empty box (a value cleared,\n * not a value of zero) and `undefined` for text it cannot read, which the\n * field refuses rather than guesses at.\n */\n\nexport interface Codec<T> {\n /** The value in the text, `null` for none, `undefined` when it cannot be\n * read. */\n parse: (text: string) => T | null | undefined\n /** The one spelling the value is shown in. */\n format: (value: T) => string\n /** Set in the monospaced face with tabular figures, so a column of them\n * lines up. */\n mono?: boolean\n /** The keyboard a phone offers. */\n inputMode?: 'text' | 'decimal' | 'numeric'\n}\n\n/** Text as it is typed. Nothing to read, so nothing to refuse. */\nexport const textCodec: Codec<string> = {\n parse: (text) => text,\n format: (value) => value,\n}\n\n/** A number, with a comma accepted as the decimal point - half the world\n * writes one. Empty is `null`, and anything else that is not a number is\n * refused. */\nexport const numberCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim().replace(',', '.')\n if (trimmed === '') return null\n if (!/^-?\\d+(?:\\.\\d+)?$/.test(trimmed)) return undefined\n return Number(trimmed)\n },\n format: (value) => String(value),\n mono: true,\n inputMode: 'decimal',\n}\n\n/**\n * Seconds as a person types them on a timeline: `83`, `1:23`, `0:04.5`,\n * `1:00:00`. A minute of seventy-five seconds is a typo and is refused, not\n * read as a minute and a quarter. Written back as `m:ss`, with a tenth only\n * when there is one - hours are not split out, because a clip is minutes long\n * and `60:00` reads better beside `0:04` than `1:00:00` does.\n */\nexport const timecodeCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return null\n const parts = trimmed.split(':')\n if (parts.length > 3) return undefined\n let seconds = 0\n for (const [index, part] of parts.entries()) {\n // Only the last part may carry a fraction; the others are whole units.\n const last = index === parts.length - 1\n if (!(last ? /^\\d+(?:\\.\\d+)?$/ : /^\\d+$/).test(part)) return undefined\n const amount = Number(part)\n if (index > 0 && amount >= 60) return undefined\n seconds = seconds * 60 + amount\n }\n return seconds\n },\n format: (seconds) => {\n if (!Number.isFinite(seconds) || seconds < 0) return ''\n // Rounded to a tenth before splitting, so 59.96 does not read as 0:60.0.\n const tenths = Math.round(seconds * 10)\n const minutes = Math.floor(tenths / 600)\n const rest = tenths - minutes * 600\n const whole = String(Math.floor(rest / 10)).padStart(2, '0')\n const fraction = rest % 10\n return fraction === 0 ? `${minutes}:${whole}` : `${minutes}:${whole}.${fraction}`\n },\n mono: true,\n}\n\ninterface InlineFieldBaseProps {\n /** What the value is called: the caption above it. */\n label: ReactNode\n /** Keep the caption for a screen reader and take it off the screen - for a\n * cell whose column header already names it. */\n labelHidden?: boolean\n /** What an empty value shows. A dash reads as \"nothing yet\"; a blank box\n * reads as a rendering fault. */\n placeholder?: string\n disabled?: boolean\n className?: string\n}\n\nexport interface InlineFieldProps<T> extends InlineFieldBaseProps {\n /** The stored value; `null` for none. */\n value: T | null\n /** Called once, with the parsed value, when a change is kept. Return\n * `false` to refuse it - a server that said no - and the field goes back\n * to the stored value. */\n onCommit: (value: T | null) => boolean | void\n codec: Codec<T>\n}\n\nexport function InlineField(\n props: InlineFieldBaseProps & {\n value: string | null\n onCommit: (value: string | null) => boolean | void\n codec?: Codec<string>\n },\n): ReactNode\nexport function InlineField<T>(props: InlineFieldProps<T>): ReactNode\nexport function InlineField<T>({\n label,\n labelHidden = false,\n placeholder,\n disabled = false,\n className,\n value,\n onCommit,\n // Text is the default, and only for a value that is text: the overloads\n // above refuse a number without a codec at compile time.\n codec = textCodec as unknown as Codec<T>,\n}: Omit<InlineFieldProps<T>, 'codec'> & { codec?: Codec<T> }) {\n const id = useId()\n const stored = value === null ? '' : codec.format(value)\n\n const draft = useFieldDraft(\n stored,\n (text) => {\n const parsed = codec.parse(text)\n // `readable` has already turned away `undefined`; this is the type\n // system's copy of the same fact.\n if (parsed === undefined) return false\n return onCommit(parsed)\n },\n { readable: (text) => codec.parse(text) !== undefined },\n )\n\n return (\n <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>\n <label htmlFor={id} className={cn('caption', labelHidden && 'sr-only')}>\n {label}\n </label>\n <input\n id={id}\n type=\"text\"\n disabled={disabled}\n placeholder={placeholder}\n inputMode={codec.inputMode}\n autoComplete=\"off\"\n spellCheck={codec.mono ? false : undefined}\n {...draft}\n className={cn(\n // Pulled left by its own padding, so the text lines up with the\n // caption above it and with plain text beside it - the box is\n // there only when it is wanted.\n '-ml-1.5 h-control-sm w-full min-w-0 rounded-sm border border-transparent bg-transparent px-1.5',\n 'text-sm text-ellipsis text-text placeholder:text-faint',\n 'cursor-text transition-colors outline-none',\n 'hover:border-line-2 hover:bg-soft',\n 'focus:border-accent focus:bg-soft',\n 'aria-invalid:border-bad aria-invalid:focus:border-bad',\n 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-transparent disabled:hover:bg-transparent',\n codec.mono && 'font-mono tabular-nums',\n )}\n />\n </div>\n )\n}\n"
|
|
1253
|
+
"content": "import { useId, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { useFieldDraft } from './field-draft'\n\n/*\n * InlineField.\n *\n * A value that is edited where it is shown: click it, type, Enter or leave to\n * keep, Escape to take it back.\n *\n * A board of fields - the numbers on a work's overview, the cells of a\n * storyboard - is read far more often than it is edited, so every value there\n * sits as text until it is touched. A modal to change one BPM is a lost\n * context and a click too many, and a column of always-on boxes is a form\n * nobody asked to fill in. kilna had nine of these editors, each with its own\n * idea of what Enter and Escape mean; this is one idea, and its rules are\n * FieldDraft's.\n *\n * **It is always an `<input>`.** Not text that swaps to an input on click: a\n * swap loses the caret position, steals a render, and leaves the value\n * unreachable by Tab. The box is simply undressed until it is hovered or\n * focused - the mockup's `.inl .v`: a border appears under the pointer, the\n * accent when you are in it.\n *\n * **What a value is, is a codec.** `parse` reads what was typed and `format`\n * spells the stored value, so the box can never show one thing and store\n * another. Text needs neither. `numberCodec` and `timecodeCodec` are the two\n * the line reached for; a product's own - a key signature, a tempo range - is\n * two functions. `parse` answers `null` for an empty box (a value cleared,\n * not a value of zero) and `undefined` for text it cannot read, which the\n * field refuses rather than guesses at.\n */\n\nexport interface Codec<T> {\n /** The value in the text, `null` for none, `undefined` when it cannot be\n * read. */\n parse: (text: string) => T | null | undefined\n /** The one spelling the value is shown in. */\n format: (value: T) => string\n /** Set in the monospaced face with tabular figures, so a column of them\n * lines up. */\n mono?: boolean\n /** The keyboard a phone offers. */\n inputMode?: 'text' | 'decimal' | 'numeric'\n}\n\n/** Text as it is typed. Nothing to read, so nothing to refuse. */\nexport const textCodec: Codec<string> = {\n parse: (text) => text,\n format: (value) => value,\n}\n\n/** A number, with a comma accepted as the decimal point - half the world\n * writes one. Empty is `null`, and anything else that is not a number is\n * refused. */\nexport const numberCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim().replace(',', '.')\n if (trimmed === '') return null\n if (!/^-?\\d+(?:\\.\\d+)?$/.test(trimmed)) return undefined\n return Number(trimmed)\n },\n format: (value) => String(value),\n mono: true,\n inputMode: 'decimal',\n}\n\n/**\n * Seconds as a person types them on a timeline: `83`, `1:23`, `0:04.5`,\n * `1:00:00`. A minute of seventy-five seconds is a typo and is refused, not\n * read as a minute and a quarter. Written back as `m:ss`, with a tenth only\n * when there is one - hours are not split out, because a clip is minutes long\n * and `60:00` reads better beside `0:04` than `1:00:00` does.\n */\nexport const timecodeCodec: Codec<number> = {\n parse: (text) => {\n const trimmed = text.trim()\n if (trimmed === '') return null\n const parts = trimmed.split(':')\n if (parts.length > 3) return undefined\n let seconds = 0\n for (const [index, part] of parts.entries()) {\n // Only the last part may carry a fraction; the others are whole units.\n const last = index === parts.length - 1\n if (!(last ? /^\\d+(?:\\.\\d+)?$/ : /^\\d+$/).test(part)) return undefined\n const amount = Number(part)\n if (index > 0 && amount >= 60) return undefined\n seconds = seconds * 60 + amount\n }\n return seconds\n },\n format: (seconds) => {\n if (!Number.isFinite(seconds) || seconds < 0) return ''\n // Rounded to a tenth before splitting, so 59.96 does not read as 0:60.0.\n const tenths = Math.round(seconds * 10)\n const minutes = Math.floor(tenths / 600)\n const rest = tenths - minutes * 600\n const whole = String(Math.floor(rest / 10)).padStart(2, '0')\n const fraction = rest % 10\n return fraction === 0 ? `${minutes}:${whole}` : `${minutes}:${whole}.${fraction}`\n },\n mono: true,\n}\n\ninterface InlineFieldBaseProps {\n /** What the value is called: the caption above it. */\n label: ReactNode\n /** Keep the caption for a screen reader and take it off the screen - for a\n * cell whose column header already names it. */\n labelHidden?: boolean\n /** What an empty value shows. A dash reads as \"nothing yet\"; a blank box\n * reads as a rendering fault. */\n placeholder?: string\n disabled?: boolean\n className?: string\n}\n\nexport interface InlineFieldProps<T> extends InlineFieldBaseProps {\n /** The stored value; `null` for none. */\n value: T | null\n /** Called once, with the parsed value, when a change is kept. Return\n * `false` to refuse it - a server that said no - and the field goes back\n * to the stored value. */\n onCommit: (value: T | null) => boolean | void\n codec: Codec<T>\n}\n\nexport function InlineField(\n props: InlineFieldBaseProps & {\n value: string | null\n onCommit: (value: string | null) => boolean | void\n codec?: Codec<string>\n },\n): ReactNode\nexport function InlineField<T>(props: InlineFieldProps<T>): ReactNode\nexport function InlineField<T>({\n label,\n labelHidden = false,\n placeholder,\n disabled = false,\n className,\n value,\n onCommit,\n // Text is the default, and only for a value that is text: the overloads\n // above refuse a number without a codec at compile time.\n codec = textCodec as unknown as Codec<T>,\n}: Omit<InlineFieldProps<T>, 'codec'> & { codec?: Codec<T> }) {\n const id = useId()\n const stored = value === null ? '' : codec.format(value)\n\n const draft = useFieldDraft(\n stored,\n (text) => {\n const parsed = codec.parse(text)\n // `readable` has already turned away `undefined`; this is the type\n // system's copy of the same fact.\n if (parsed === undefined) return false\n return onCommit(parsed)\n },\n { readable: (text) => codec.parse(text) !== undefined },\n )\n\n return (\n <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>\n <label htmlFor={id} className={cn('caption', labelHidden && 'sr-only')}>\n {label}\n </label>\n <input\n id={id}\n type=\"text\"\n disabled={disabled}\n placeholder={placeholder}\n inputMode={codec.inputMode}\n autoComplete=\"off\"\n spellCheck={codec.mono ? false : undefined}\n {...draft}\n className={cn(\n // Pulled left by its own padding, so the text lines up with the\n // caption above it and with plain text beside it - the box is\n // there only when it is wanted.\n '-ml-1.5 h-control-sm w-full min-w-0 rounded-sm border border-transparent bg-transparent px-1.5',\n // 16px under a finger, for the reason `fieldClasses` gives.\n 'text-sm pointer-coarse:text-lg text-ellipsis text-text placeholder:text-faint',\n 'cursor-text transition-colors outline-none',\n 'hover:border-line-2 hover:bg-soft',\n 'focus:border-accent focus:bg-soft',\n 'aria-invalid:border-bad aria-invalid:focus:border-bad',\n 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-transparent disabled:hover:bg-transparent',\n codec.mono && 'font-mono tabular-nums',\n )}\n />\n </div>\n )\n}\n"
|
|
1233
1254
|
}
|
|
1234
1255
|
]
|
|
1235
1256
|
},
|
|
@@ -1239,7 +1260,7 @@
|
|
|
1239
1260
|
"title": "Input",
|
|
1240
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.",
|
|
1241
1262
|
"dependencies": [
|
|
1242
|
-
"dowel-ui@^0.
|
|
1263
|
+
"dowel-ui@^0.37.0"
|
|
1243
1264
|
],
|
|
1244
1265
|
"registryDependencies": [],
|
|
1245
1266
|
"files": [
|
|
@@ -1247,7 +1268,7 @@
|
|
|
1247
1268
|
"path": "ui/input.tsx",
|
|
1248
1269
|
"target": "@ui/input.tsx",
|
|
1249
1270
|
"type": "registry:ui",
|
|
1250
|
-
"content": "import type { InputHTMLAttributes, Ref } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Input.\n *\n * A single-line field. It is a plain `<input>` with the line's clothes on, so\n * everything a browser gives an input for free - autofill, spellcheck, the\n * right keyboard on a phone, `type=\"email\"` validation - still works.\n *\n * The focus ring is the accent, drawn outside the border rather than replacing\n * it: a field that only changes colour on focus is invisible to anyone who\n * cannot distinguish those two colours.\n */\nexport const fieldClasses = cn(\n 'w-full rounded-md border border-line bg-transparent px-2.5 py-1.5',\n 'text-sm text-text placeholder:text-faint',\n 'transition-colors hover:border-line-2',\n 'focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent',\n // A field nobody can type in should look like one.\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // `aria-invalid` rather than a prop: the attribute is what a screen reader\n // reads, so making it the source of the colour keeps the two in step.\n 'aria-invalid:border-bad aria-invalid:focus-visible:outline-bad',\n)\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n /** React 19 passes `ref` as a plain prop; it is declared so callers can\n * reach the element to focus it or read its selection. */\n ref?: Ref<HTMLInputElement>\n}\n\nexport function Input({ className, ref, ...props }: InputProps) {\n return <input ref={ref} className={cn(fieldClasses, 'h-control', className)} {...props} />\n}\n"
|
|
1271
|
+
"content": "import type { InputHTMLAttributes, Ref } from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Input.\n *\n * A single-line field. It is a plain `<input>` with the line's clothes on, so\n * everything a browser gives an input for free - autofill, spellcheck, the\n * right keyboard on a phone, `type=\"email\"` validation - still works.\n *\n * The focus ring is the accent, drawn outside the border rather than replacing\n * it: a field that only changes colour on focus is invisible to anyone who\n * cannot distinguish those two colours.\n */\nexport const fieldClasses = cn(\n 'w-full rounded-md border border-line bg-transparent px-2.5 py-1.5',\n /* 16px under a finger. iOS Safari zooms the page into any field whose\n * text is smaller when it takes the focus, and does not zoom back out - on\n * a sign-in screen that is the form at 130% with the password off the side\n * of the screen. Keyed to the pointer rather than the width: a phone on its\n * side is wider than `sm` and zooms all the same, and a narrow desktop\n * window has a mouse and wants the set's 12px. A different variant from a\n * size's own `text-xs` or `text-base`, so it survives every one of them.\n * `tests/entry-text.test.ts` holds every field in the set to it. */\n 'text-sm pointer-coarse:text-lg text-text placeholder:text-faint',\n 'transition-colors hover:border-line-2',\n 'focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent',\n // A field nobody can type in should look like one.\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // `aria-invalid` rather than a prop: the attribute is what a screen reader\n // reads, so making it the source of the colour keeps the two in step.\n 'aria-invalid:border-bad aria-invalid:focus-visible:outline-bad',\n)\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n /** React 19 passes `ref` as a plain prop; it is declared so callers can\n * reach the element to focus it or read its selection. */\n ref?: Ref<HTMLInputElement>\n}\n\nexport function Input({ className, ref, ...props }: InputProps) {\n return <input ref={ref} className={cn(fieldClasses, 'h-control', className)} {...props} />\n}\n"
|
|
1251
1272
|
}
|
|
1252
1273
|
]
|
|
1253
1274
|
},
|
|
@@ -1273,7 +1294,7 @@
|
|
|
1273
1294
|
"title": "JsonViewer",
|
|
1274
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.",
|
|
1275
1296
|
"dependencies": [
|
|
1276
|
-
"dowel-ui@^0.
|
|
1297
|
+
"dowel-ui@^0.37.0"
|
|
1277
1298
|
],
|
|
1278
1299
|
"registryDependencies": [
|
|
1279
1300
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1293,7 +1314,7 @@
|
|
|
1293
1314
|
"title": "Kbd",
|
|
1294
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.",
|
|
1295
1316
|
"dependencies": [
|
|
1296
|
-
"dowel-ui@^0.
|
|
1317
|
+
"dowel-ui@^0.37.0"
|
|
1297
1318
|
],
|
|
1298
1319
|
"registryDependencies": [
|
|
1299
1320
|
"https://lacodda.github.io/dowel/r/shortcut.json"
|
|
@@ -1314,7 +1335,7 @@
|
|
|
1314
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.",
|
|
1315
1336
|
"dependencies": [
|
|
1316
1337
|
"class-variance-authority",
|
|
1317
|
-
"dowel-ui@^0.
|
|
1338
|
+
"dowel-ui@^0.37.0"
|
|
1318
1339
|
],
|
|
1319
1340
|
"registryDependencies": [],
|
|
1320
1341
|
"files": [
|
|
@@ -1349,7 +1370,7 @@
|
|
|
1349
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.",
|
|
1350
1371
|
"dependencies": [
|
|
1351
1372
|
"class-variance-authority",
|
|
1352
|
-
"dowel-ui@^0.
|
|
1373
|
+
"dowel-ui@^0.37.0"
|
|
1353
1374
|
],
|
|
1354
1375
|
"registryDependencies": [
|
|
1355
1376
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1386,7 +1407,7 @@
|
|
|
1386
1407
|
"description": "A row of a list, one thing said in a line: ListRow for a fact, RowButton for a choice.",
|
|
1387
1408
|
"dependencies": [
|
|
1388
1409
|
"@base-ui/react",
|
|
1389
|
-
"dowel-ui@^0.
|
|
1410
|
+
"dowel-ui@^0.37.0"
|
|
1390
1411
|
],
|
|
1391
1412
|
"registryDependencies": [],
|
|
1392
1413
|
"files": [
|
|
@@ -1394,7 +1415,7 @@
|
|
|
1394
1415
|
"path": "ui/list-row.tsx",
|
|
1395
1416
|
"target": "@ui/list-row.tsx",
|
|
1396
1417
|
"type": "registry:ui",
|
|
1397
|
-
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * ListRow and RowButton.\n *\n * A row of a list, one thing said in a line: ListRow for a fact, RowButton for\n * a choice.\n *\n * Every screen of every product has a list of something - versions, releases,\n * a queue, a journal - and each drew its own row: kilna alone had six\n * list-and-detail layouts with five different row recipes, and the selected\n * row was tinted four different ways. The row is three slots and a rule\n * about truncation, which is small enough to get wrong every time and\n * exactly why it is here.\n *\n * Two components, because a row is either a fact or a choice:\n *\n * - `ListRow` is a fact - \"Clip, 02.09\" in a list of releases. It is not\n * pressable, has no hover, and rows in a run are divided by a hairline.\n * - `RowButton` is a choice - a version in the list beside the editor, an\n * item in a queue. It is a real `<button>` (or the product's link, through\n * `render`), it answers the pointer, and the one that is open is tinted on\n * `accent-soft` - the mockup's `.vrow.on` - and named as current, because\n * the tint alone says it to nobody who cannot see it.\n *\n * The slots are the same in both: `start` for the thing that identifies the\n * row at a glance (an icon, a status dot, a cover), the row's words with an\n * optional `description` under them, and `end` for what is measured about it\n * (a date, a count, a chip). The words truncate on one line and the slots do\n * not shrink: a list whose rows grow to two lines in one language and not in\n * the other stops being a list you can scan.\n */\n\nconst slots = {\n start: 'flex shrink-0 items-center text-dim [&_svg:not([class*=size-])]:size-4',\n end: 'ml-auto flex shrink-0 items-center gap-1.5 text-xs text-
|
|
1418
|
+
"content": "import type { HTMLAttributes, ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * ListRow and RowButton.\n *\n * A row of a list, one thing said in a line: ListRow for a fact, RowButton for\n * a choice.\n *\n * Every screen of every product has a list of something - versions, releases,\n * a queue, a journal - and each drew its own row: kilna alone had six\n * list-and-detail layouts with five different row recipes, and the selected\n * row was tinted four different ways. The row is three slots and a rule\n * about truncation, which is small enough to get wrong every time and\n * exactly why it is here.\n *\n * Two components, because a row is either a fact or a choice:\n *\n * - `ListRow` is a fact - \"Clip, 02.09\" in a list of releases. It is not\n * pressable, has no hover, and rows in a run are divided by a hairline.\n * - `RowButton` is a choice - a version in the list beside the editor, an\n * item in a queue. It is a real `<button>` (or the product's link, through\n * `render`), it answers the pointer, and the one that is open is tinted on\n * `accent-soft` - the mockup's `.vrow.on` - and named as current, because\n * the tint alone says it to nobody who cannot see it.\n *\n * The slots are the same in both: `start` for the thing that identifies the\n * row at a glance (an icon, a status dot, a cover), the row's words with an\n * optional `description` under them, and `end` for what is measured about it\n * (a date, a count, a chip). The words truncate on one line and the slots do\n * not shrink: a list whose rows grow to two lines in one language and not in\n * the other stops being a list you can scan.\n */\n\n/* The quiet parts are `dim`, not `faint`. `--faint` is verified against the\n * grounds at body size, and at 11px under a row's title a live axe run\n * measured it below AA in both themes - on every list beside a detail, which\n * is every block that has a list. The title against them is `text`, so the\n * row still reads as a title and its notes. */\nconst slots = {\n start: 'flex shrink-0 items-center text-dim [&_svg:not([class*=size-])]:size-4',\n end: 'ml-auto flex shrink-0 items-center gap-1.5 text-xs text-dim tabular-nums',\n} as const\n\ninterface RowSlots {\n /** What identifies the row at a glance: an icon, a status dot, a cover. */\n start?: ReactNode\n /** A second line under the words, quieter and truncated the same way. */\n description?: ReactNode\n /** What is measured about the row: a date, a count, a chip. */\n end?: ReactNode\n /** The row's words. */\n children: ReactNode\n}\n\nfunction RowBody({ start, description, end, children, strong }: RowSlots & { strong?: boolean }) {\n return (\n <>\n {start === undefined ? null : (\n <span data-slot=\"start\" className={slots.start}>\n {start}\n </span>\n )}\n {/* `min-w-0` because a flex child refuses to shrink below its content\n without it - and a row that does not shrink pushes its `end` out of\n the list instead of truncating its words. */}\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className={cn('truncate', strong && 'font-semibold')}>{children}</span>\n {description === undefined ? null : <span className=\"truncate text-xs text-dim\">{description}</span>}\n </span>\n {end === undefined ? null : <span className={slots.end}>{end}</span>}\n </>\n )\n}\n\nexport interface ListRowProps extends RowSlots, Omit<HTMLAttributes<HTMLElement>, 'children'> {\n /** Something other than a `div` - an `<li>` in a `<ul>`, most often:\n * `render={<li />}`. */\n render?: useRender.RenderProp\n}\n\n/**\n * A row that states a fact. Rows in a run are divided by a hairline that the\n * last one does not draw, so a list inside a panel ends at the panel's edge\n * rather than on a line of its own.\n *\n * Its height comes from `py-row`, so a list follows `data-density` with the\n * table beside it.\n */\nexport function ListRow({ start, description, end, children, render, className, ...props }: ListRowProps) {\n return useRender({\n render,\n defaultTagName: 'div',\n props: {\n className: cn(\n 'flex min-w-0 items-center gap-2.5 border-b border-line px-3 py-row text-sm text-text last:border-b-0',\n className,\n ),\n children: (\n <RowBody start={start} description={description} end={end}>\n {children}\n </RowBody>\n ),\n ...props,\n },\n })\n}\n\nexport interface RowButtonProps extends RowSlots, Omit<HTMLAttributes<HTMLElement>, 'children'> {\n /** The open one - tinted, and named as current. */\n selected?: boolean\n /** The element to draw the row as - a router's link, most often:\n * `render={<NavLink to={…} />}`. Without it the row is a `<button>`. */\n render?: useRender.RenderProp\n disabled?: boolean\n}\n\n/**\n * A row that is a choice.\n *\n * `aria-current` says which one is open, the way a list beside an editor\n * works: the row is where you are, not a value you are submitting. A product\n * that puts rows in a `listbox` passes `aria-selected` instead, and the\n * attribute it passes wins.\n */\nexport function RowButton({\n start,\n description,\n end,\n children,\n selected = false,\n render,\n disabled,\n className,\n ...props\n}: RowButtonProps) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n // A `<button>` inside a form submits it unless told otherwise, and a\n // list of rows inside a settings form is where that bites.\n ...(render === undefined ? { type: 'button', disabled } : {}),\n 'aria-current': selected ? 'true' : undefined,\n className: cn(\n 'flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-text no-underline',\n 'transition-colors hover:bg-soft',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n // The open row's icon takes the accent, as a NavRail entry's does.\n selected && 'bg-accent-soft hover:bg-accent-soft [&>[data-slot=start]]:text-accent',\n className,\n ),\n children: (\n <RowBody start={start} description={description} end={end} strong>\n {children}\n </RowBody>\n ),\n ...props,\n },\n })\n}\n"
|
|
1398
1419
|
}
|
|
1399
1420
|
]
|
|
1400
1421
|
},
|
|
@@ -1404,7 +1425,7 @@
|
|
|
1404
1425
|
"title": "MarkedText",
|
|
1405
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.",
|
|
1406
1427
|
"dependencies": [
|
|
1407
|
-
"dowel-ui@^0.
|
|
1428
|
+
"dowel-ui@^0.37.0"
|
|
1408
1429
|
],
|
|
1409
1430
|
"registryDependencies": [],
|
|
1410
1431
|
"files": [
|
|
@@ -1412,7 +1433,7 @@
|
|
|
1412
1433
|
"path": "ui/marked-text.tsx",
|
|
1413
1434
|
"target": "@ui/marked-text.tsx",
|
|
1414
1435
|
"type": "registry:ui",
|
|
1415
|
-
"content": "import {\n useCallback,\n useMemo,\n type ChangeEvent,\n type CSSProperties,\n type ReactNode,\n type Ref,\n type TextareaHTMLAttributes,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Text with marks on it - read, or typed into.\n *\n * A textarea cannot colour a word. The way round it is older than React: draw\n * the same text twice, once as marked-up HTML underneath and once as the\n * textarea on top with its own text transparent, so the caret and the\n * selection are the browser's and the colours are ours. The two have to agree\n * on every metric - font, size, line height, padding, wrapping - or the marks\n * slide off the words they mark. So both take ONE class list, given by the\n * caller, and the textarea adds only what makes it invisible.\n *\n * The mirror is also what sizes the box. It sits in the flow and the textarea\n * is stretched over it, which means the field grows with its text without a\n * `scrollHeight` measurement and never scrolls inside itself - the box around\n * it scrolls, and the marks scroll with the words. A trailing space on the\n * last line keeps a final newline from being a line the mirror forgot.\n *\n * Two kinds of mark: a span of characters (a repeated word, a misspelling)\n * and a whole line (a line that is new since the other version). Lines are\n * drawn as blocks rather than separated by newlines so a line mark can paint\n * the full width; a block per line wraps exactly as the textarea's line does,\n * since it is the same text in the same width with the same font.\n */\n\n/** A span of characters, as offsets into the whole text. */\nexport interface Mark {\n start: number\n end: number\n className?: string\n style?: CSSProperties\n}\n\n/** A whole line. */\nexport interface LineMark {\n /** Zero-based line index. */\n line: number\n className?: string\n}\n\nexport interface MarkedLinesProps {\n text: string\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** Transparent text: the textarea above shows the letters. */\n ghost?: boolean\n}\n\n/** One line cut into runs, each run under at most one mark. */\nfunction runs(line: string, offset: number, marks: readonly Mark[]): ReactNode[] {\n const out: ReactNode[] = []\n let at = 0\n for (const mark of marks) {\n const start = Math.max(mark.start - offset, 0)\n const end = Math.min(mark.end - offset, line.length)\n if (end <= at || start >= line.length) continue\n if (start > at) out.push(line.slice(at, start))\n out.push(\n <mark\n key={offset + start}\n // `<mark>` for the semantics; the browser's yellow is dropped so the\n // caller's class is the colour, in whichever tone the mark means.\n className={cn('rounded-sm bg-transparent text-inherit', mark.className)}\n style={mark.style}\n >\n {line.slice(start, end)}\n </mark>,\n )\n at = end\n }\n if (at < line.length) out.push(line.slice(at))\n return out\n}\n\n/** The marked-up text, line by line. The layer both the readable and the\n * typeable form are made of. */\nexport function MarkedLines({ text, marks = [], lineMarks = [], ghost = false }: MarkedLinesProps) {\n // Each line with where it starts in the text, so a mark given as an offset\n // into the whole can be cut to the line it falls on.\n const lines = useMemo(() => {\n const out: { line: string; start: number }[] = []\n let offset = 0\n for (const line of text.split('\\n')) {\n out.push({ line, start: offset })\n offset += line.length + 1\n }\n return out\n }, [text])\n const byLine = useMemo(() => {\n const map = new Map<number, string | undefined>()\n for (const mark of lineMarks) map.set(mark.line, mark.className)\n return map\n }, [lineMarks])\n\n return (\n <>\n {lines.map(({ line, start }, index) => {\n // Only the marks that touch this line, in order; a text has few marks\n // and few lines, so a filter per line is cheaper than an index.\n const own = marks.filter((mark) => mark.end > start && mark.start < start + line.length)\n return (\n <div\n key={index}\n data-line={index}\n className={cn(\n 'min-h-[1lh] whitespace-pre-wrap [overflow-wrap:anywhere]',\n ghost && 'text-transparent',\n byLine.get(index),\n )}\n >\n {/* A blank line still needs its height, and a trailing line needs\n a character to exist at all. */}\n {line === '' ? ' ' : runs(line, start, own)}\n {index === lines.length - 1 && ' '}\n </div>\n )\n })}\n </>\n )\n}\n\nexport interface MarkedTextProps extends MarkedLinesProps {\n className?: string\n}\n\n/** Marked text to read. */\nexport function MarkedText({ className, ...lines }: MarkedTextProps) {\n return (\n <div className={cn('select-text', className)}>\n <MarkedLines {...lines} />\n </div>\n )\n}\n\nexport interface MarkedTextareaProps\n extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'className'> {\n ref?: Ref<HTMLTextAreaElement>\n value: string\n /** The new value, not the event: the event is the textarea's business. */\n onChange: (value: string) => void\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** The metrics both layers share: font, size, line height, padding. */\n className?: string\n}\n\n/** Marked text to type into. */\nexport function MarkedTextarea({\n value,\n onChange,\n marks,\n lineMarks,\n className,\n ref,\n ...props\n}: MarkedTextareaProps) {\n const change = useCallback(\n (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value),\n [onChange],\n )\n\n return (\n <div className=\"relative\">\n <div aria-hidden data-mirror className={cn('pointer-events-none', className)}>\n <MarkedLines text={value} marks={marks} lineMarks={lineMarks} ghost />\n </div>\n <textarea\n ref={ref}\n value={value}\n onChange={change}\n spellCheck={false}\n className={cn(\n // The same metrics, and nothing that would draw: the letters, the\n // caret and the selection are what this layer is for.\n 'absolute inset-0 h-full w-full resize-none overflow-hidden text-text outline-none',\n '[overflow-wrap:anywhere] whitespace-pre-wrap',\n className,\n // After the caller's classes, on purpose. A background in the\n // metrics is meant for the box and lands on both layers; on this\n // one it would paint over every mark. The mirror keeps it, the\n // field never does.\n 'bg-transparent',\n )}\n {...props}\n />\n </div>\n )\n}\n"
|
|
1436
|
+
"content": "import {\n useCallback,\n useMemo,\n type ChangeEvent,\n type CSSProperties,\n type ReactNode,\n type Ref,\n type TextareaHTMLAttributes,\n} from 'react'\nimport { cn } from 'dowel-ui'\n\n/*\n * Text with marks on it - read, or typed into.\n *\n * A textarea cannot colour a word. The way round it is older than React: draw\n * the same text twice, once as marked-up HTML underneath and once as the\n * textarea on top with its own text transparent, so the caret and the\n * selection are the browser's and the colours are ours. The two have to agree\n * on every metric - font, size, line height, padding, wrapping - or the marks\n * slide off the words they mark. So both take ONE class list, given by the\n * caller, and the textarea adds only what makes it invisible.\n *\n * The mirror is also what sizes the box. It sits in the flow and the textarea\n * is stretched over it, which means the field grows with its text without a\n * `scrollHeight` measurement and never scrolls inside itself - the box around\n * it scrolls, and the marks scroll with the words. A trailing space on the\n * last line keeps a final newline from being a line the mirror forgot.\n *\n * Two kinds of mark: a span of characters (a repeated word, a misspelling)\n * and a whole line (a line that is new since the other version). Lines are\n * drawn as blocks rather than separated by newlines so a line mark can paint\n * the full width; a block per line wraps exactly as the textarea's line does,\n * since it is the same text in the same width with the same font.\n */\n\n/** A span of characters, as offsets into the whole text. */\nexport interface Mark {\n start: number\n end: number\n className?: string\n style?: CSSProperties\n}\n\n/** A whole line. */\nexport interface LineMark {\n /** Zero-based line index. */\n line: number\n className?: string\n}\n\nexport interface MarkedLinesProps {\n text: string\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** Transparent text: the textarea above shows the letters. */\n ghost?: boolean\n}\n\n/** One line cut into runs, each run under at most one mark. */\nfunction runs(line: string, offset: number, marks: readonly Mark[]): ReactNode[] {\n const out: ReactNode[] = []\n let at = 0\n for (const mark of marks) {\n const start = Math.max(mark.start - offset, 0)\n const end = Math.min(mark.end - offset, line.length)\n if (end <= at || start >= line.length) continue\n if (start > at) out.push(line.slice(at, start))\n out.push(\n <mark\n key={offset + start}\n // `<mark>` for the semantics; the browser's yellow is dropped so the\n // caller's class is the colour, in whichever tone the mark means.\n className={cn('rounded-sm bg-transparent text-inherit', mark.className)}\n style={mark.style}\n >\n {line.slice(start, end)}\n </mark>,\n )\n at = end\n }\n if (at < line.length) out.push(line.slice(at))\n return out\n}\n\n/** The marked-up text, line by line. The layer both the readable and the\n * typeable form are made of. */\nexport function MarkedLines({ text, marks = [], lineMarks = [], ghost = false }: MarkedLinesProps) {\n // Each line with where it starts in the text, so a mark given as an offset\n // into the whole can be cut to the line it falls on.\n const lines = useMemo(() => {\n const out: { line: string; start: number }[] = []\n let offset = 0\n for (const line of text.split('\\n')) {\n out.push({ line, start: offset })\n offset += line.length + 1\n }\n return out\n }, [text])\n const byLine = useMemo(() => {\n const map = new Map<number, string | undefined>()\n for (const mark of lineMarks) map.set(mark.line, mark.className)\n return map\n }, [lineMarks])\n\n return (\n <>\n {lines.map(({ line, start }, index) => {\n // Only the marks that touch this line, in order; a text has few marks\n // and few lines, so a filter per line is cheaper than an index.\n const own = marks.filter((mark) => mark.end > start && mark.start < start + line.length)\n return (\n <div\n key={index}\n data-line={index}\n className={cn(\n 'min-h-[1lh] whitespace-pre-wrap [overflow-wrap:anywhere]',\n ghost && 'text-transparent',\n byLine.get(index),\n )}\n >\n {/* A blank line still needs its height, and a trailing line needs\n a character to exist at all. */}\n {line === '' ? ' ' : runs(line, start, own)}\n {index === lines.length - 1 && ' '}\n </div>\n )\n })}\n </>\n )\n}\n\nexport interface MarkedTextProps extends MarkedLinesProps {\n className?: string\n}\n\n/** Marked text to read. */\nexport function MarkedText({ className, ...lines }: MarkedTextProps) {\n return (\n <div className={cn('select-text', className)}>\n <MarkedLines {...lines} />\n </div>\n )\n}\n\nexport interface MarkedTextareaProps\n extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'className'> {\n ref?: Ref<HTMLTextAreaElement>\n value: string\n /** The new value, not the event: the event is the textarea's business. */\n onChange: (value: string) => void\n marks?: readonly Mark[]\n lineMarks?: readonly LineMark[]\n /** The metrics both layers share: font, size, line height, padding. */\n className?: string\n}\n\n/** Marked text to type into. */\nexport function MarkedTextarea({\n value,\n onChange,\n marks,\n lineMarks,\n className,\n ref,\n ...props\n}: MarkedTextareaProps) {\n const change = useCallback(\n (event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value),\n [onChange],\n )\n\n return (\n <div className=\"relative\">\n {/* Both layers take the same size under a finger - 16px, for the reason\n `fieldClasses` gives - or the marks drift off the letters they mark. */}\n <div aria-hidden data-mirror className={cn('pointer-events-none pointer-coarse:text-lg', className)}>\n <MarkedLines text={value} marks={marks} lineMarks={lineMarks} ghost />\n </div>\n <textarea\n ref={ref}\n value={value}\n onChange={change}\n spellCheck={false}\n className={cn(\n // The same metrics, and nothing that would draw: the letters, the\n // caret and the selection are what this layer is for.\n 'absolute inset-0 h-full w-full resize-none overflow-hidden pointer-coarse:text-lg text-text outline-none',\n '[overflow-wrap:anywhere] whitespace-pre-wrap',\n className,\n // After the caller's classes, on purpose. A background in the\n // metrics is meant for the box and lands on both layers; on this\n // one it would paint over every mark. The mirror keeps it, the\n // field never does.\n 'bg-transparent',\n )}\n {...props}\n />\n </div>\n )\n}\n"
|
|
1416
1437
|
}
|
|
1417
1438
|
]
|
|
1418
1439
|
},
|
|
@@ -1424,7 +1445,7 @@
|
|
|
1424
1445
|
"dependencies": [
|
|
1425
1446
|
"@base-ui/react",
|
|
1426
1447
|
"class-variance-authority",
|
|
1427
|
-
"dowel-ui@^0.
|
|
1448
|
+
"dowel-ui@^0.37.0"
|
|
1428
1449
|
],
|
|
1429
1450
|
"registryDependencies": [
|
|
1430
1451
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1446,7 +1467,7 @@
|
|
|
1446
1467
|
"dependencies": [
|
|
1447
1468
|
"@base-ui/react",
|
|
1448
1469
|
"class-variance-authority",
|
|
1449
|
-
"dowel-ui@^0.
|
|
1470
|
+
"dowel-ui@^0.37.0"
|
|
1450
1471
|
],
|
|
1451
1472
|
"registryDependencies": [
|
|
1452
1473
|
"https://lacodda.github.io/dowel/r/tooltip.json"
|
|
@@ -1466,7 +1487,7 @@
|
|
|
1466
1487
|
"title": "NotificationBell",
|
|
1467
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.",
|
|
1468
1489
|
"dependencies": [
|
|
1469
|
-
"dowel-ui@^0.
|
|
1490
|
+
"dowel-ui@^0.37.0"
|
|
1470
1491
|
],
|
|
1471
1492
|
"registryDependencies": [
|
|
1472
1493
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1488,7 +1509,7 @@
|
|
|
1488
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.",
|
|
1489
1510
|
"dependencies": [
|
|
1490
1511
|
"@base-ui/react",
|
|
1491
|
-
"dowel-ui@^0.
|
|
1512
|
+
"dowel-ui@^0.37.0"
|
|
1492
1513
|
],
|
|
1493
1514
|
"registryDependencies": [
|
|
1494
1515
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1498,7 +1519,7 @@
|
|
|
1498
1519
|
"path": "ui/number-field.tsx",
|
|
1499
1520
|
"target": "@ui/number-field.tsx",
|
|
1500
1521
|
"type": "registry:ui",
|
|
1501
|
-
"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"
|
|
1502
1523
|
}
|
|
1503
1524
|
]
|
|
1504
1525
|
},
|
|
@@ -1508,7 +1529,7 @@
|
|
|
1508
1529
|
"title": "NumberFormat",
|
|
1509
1530
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1510
1531
|
"dependencies": [
|
|
1511
|
-
"dowel-ui@^0.
|
|
1532
|
+
"dowel-ui@^0.37.0"
|
|
1512
1533
|
],
|
|
1513
1534
|
"registryDependencies": [],
|
|
1514
1535
|
"files": [
|
|
@@ -1527,7 +1548,7 @@
|
|
|
1527
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.",
|
|
1528
1549
|
"dependencies": [
|
|
1529
1550
|
"class-variance-authority",
|
|
1530
|
-
"dowel-ui@^0.
|
|
1551
|
+
"dowel-ui@^0.37.0"
|
|
1531
1552
|
],
|
|
1532
1553
|
"registryDependencies": [],
|
|
1533
1554
|
"files": [
|
|
@@ -1545,7 +1566,7 @@
|
|
|
1545
1566
|
"title": "PageSize",
|
|
1546
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.",
|
|
1547
1568
|
"dependencies": [
|
|
1548
|
-
"dowel-ui@^0.
|
|
1569
|
+
"dowel-ui@^0.37.0"
|
|
1549
1570
|
],
|
|
1550
1571
|
"registryDependencies": [
|
|
1551
1572
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1565,7 +1586,7 @@
|
|
|
1565
1586
|
"title": "Pagination",
|
|
1566
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.",
|
|
1567
1588
|
"dependencies": [
|
|
1568
|
-
"dowel-ui@^0.
|
|
1589
|
+
"dowel-ui@^0.37.0"
|
|
1569
1590
|
],
|
|
1570
1591
|
"registryDependencies": [
|
|
1571
1592
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1586,7 +1607,7 @@
|
|
|
1586
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.",
|
|
1587
1608
|
"dependencies": [
|
|
1588
1609
|
"class-variance-authority",
|
|
1589
|
-
"dowel-ui@^0.
|
|
1610
|
+
"dowel-ui@^0.37.0"
|
|
1590
1611
|
],
|
|
1591
1612
|
"registryDependencies": [],
|
|
1592
1613
|
"files": [
|
|
@@ -1604,7 +1625,7 @@
|
|
|
1604
1625
|
"title": "PasswordField",
|
|
1605
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.",
|
|
1606
1627
|
"dependencies": [
|
|
1607
|
-
"dowel-ui@^0.
|
|
1628
|
+
"dowel-ui@^0.37.0"
|
|
1608
1629
|
],
|
|
1609
1630
|
"registryDependencies": [
|
|
1610
1631
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1614,7 +1635,7 @@
|
|
|
1614
1635
|
"path": "ui/password-field.tsx",
|
|
1615
1636
|
"target": "@ui/password-field.tsx",
|
|
1616
1637
|
"type": "registry:ui",
|
|
1617
|
-
"content": "import { useId, useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * PasswordField - a password, and the button that shows it.\n *\n * The reveal is the whole component, and it is not a convenience. A masked\n * field is the only one in a form where a typo cannot be seen, so people\n * either paste (fine) or type slowly and get it wrong anyway; the toggle is\n * what turns an unverifiable field into a checkable one, and it is why long\n * passphrases became usable at all.\n *\n * What it costs is a moment where the password is on the screen, so the\n * component states its two rules rather than leaving them to each product:\n *\n * - it always starts masked, and there is no prop to start it revealed;\n * - revealing is the reader's own action, never a default and never\n * something a form can turn on for them.\n *\n * The button is a real button with a real name, and the name changes with the\n * state - \"Show password\" / \"Hide password\". That is what a screen reader\n * announces, and it is the one place the component needs words, so they are\n * required props. A default here would ship English inside a primitive.\n *\n * `autoComplete` is not defaulted either. The right value is the product's\n * to know: `current-password` on a login, `new-password` on a sign-up, and\n * getting it wrong is how a password manager fills the wrong box.\n */\n\nexport interface PasswordFieldProps {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n /** What the reveal button is called while the password is hidden. */\n showLabel: string\n /** ...and while it is showing. */\n hideLabel: string\n /** `current-password` for a login, `new-password` for a sign-up. */\n autoComplete?: string\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function PasswordField({\n value,\n defaultValue,\n onValueChange,\n showLabel,\n hideLabel,\n className,\n disabled,\n ref,\n ...props\n}: PasswordFieldProps) {\n /* Always false to begin with. Deliberately local state with no prop to set\n * it: a password that arrives on screen without the reader asking is the\n * one failure this component must not have. */\n const [revealed, setRevealed] = useState(false)\n const inputId = useId()\n\n return (\n <div\n className={cn(\n fieldClasses,\n 'flex h-control items-stretch overflow-hidden p-0',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n >\n <input\n {...props}\n ref={ref}\n id={props.id ?? inputId}\n type={revealed ? 'text' : 'password'}\n value={value}\n defaultValue={defaultValue}\n disabled={disabled}\n onChange={(event) => onValueChange?.(event.target.value)}\n className={cn(\n 'w-full min-w-0 bg-transparent px-2.5 text-sm text-text placeholder:text-faint',\n 'outline-none disabled:cursor-not-allowed',\n )}\n />\n\n <button\n type=\"button\"\n // Not a submit button, and not in the tab order ahead of the field it\n // belongs to - it sits after the input, which is where Tab reaches it.\n onClick={() => setRevealed((was) => !was)}\n disabled={disabled}\n aria-label={revealed ? hideLabel : showLabel}\n aria-pressed={revealed}\n aria-controls={props.id ?? inputId}\n className={cn(\n 'flex w-9 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {revealed ? (\n /* An eye with a stroke through it: hiding is the action offered\n * while the password is visible. */\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M4 4l12 12M8.5 8.7a2 2 0 002.8 2.8M6.3 6.4C4.4 7.5 3 9.2 2.5 10c1.2 2.2 4 5 7.5 5 1.3 0 2.5-.4 3.5-1M9 5.1c.3 0 .7-.1 1-.1 3.5 0 6.3 2.8 7.5 5-.3.5-.8 1.3-1.6 2.1\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n />\n </svg>\n ) : (\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M2.5 10C3.7 7.8 6.5 5 10 5s6.3 2.8 7.5 5c-1.2 2.2-4 5-7.5 5s-6.3-2.8-7.5-5z\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n />\n <circle cx=\"10\" cy=\"10\" r=\"2.2\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n </svg>\n )}\n </button>\n </div>\n )\n}\n"
|
|
1638
|
+
"content": "import { useId, useState, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * PasswordField - a password, and the button that shows it.\n *\n * The reveal is the whole component, and it is not a convenience. A masked\n * field is the only one in a form where a typo cannot be seen, so people\n * either paste (fine) or type slowly and get it wrong anyway; the toggle is\n * what turns an unverifiable field into a checkable one, and it is why long\n * passphrases became usable at all.\n *\n * What it costs is a moment where the password is on the screen, so the\n * component states its two rules rather than leaving them to each product:\n *\n * - it always starts masked, and there is no prop to start it revealed;\n * - revealing is the reader's own action, never a default and never\n * something a form can turn on for them.\n *\n * The button is a real button with a real name, and the name changes with the\n * state - \"Show password\" / \"Hide password\". That is what a screen reader\n * announces, and it is the one place the component needs words, so they are\n * required props. A default here would ship English inside a primitive.\n *\n * `autoComplete` is not defaulted either. The right value is the product's\n * to know: `current-password` on a login, `new-password` on a sign-up, and\n * getting it wrong is how a password manager fills the wrong box.\n */\n\nexport interface PasswordFieldProps {\n value?: string\n defaultValue?: string\n onValueChange?: (value: string) => void\n /** What the reveal button is called while the password is hidden. */\n showLabel: string\n /** ...and while it is showing. */\n hideLabel: string\n /** `current-password` for a login, `new-password` for a sign-up. */\n autoComplete?: string\n placeholder?: string\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n id?: string\n ref?: Ref<HTMLInputElement>\n 'aria-label'?: string\n 'aria-describedby'?: string\n className?: string\n}\n\nexport function PasswordField({\n value,\n defaultValue,\n onValueChange,\n showLabel,\n hideLabel,\n className,\n disabled,\n ref,\n ...props\n}: PasswordFieldProps) {\n /* Always false to begin with. Deliberately local state with no prop to set\n * it: a password that arrives on screen without the reader asking is the\n * one failure this component must not have. */\n const [revealed, setRevealed] = useState(false)\n const inputId = useId()\n\n return (\n <div\n className={cn(\n fieldClasses,\n 'flex h-control items-stretch overflow-hidden p-0',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n className,\n )}\n >\n <input\n {...props}\n ref={ref}\n id={props.id ?? inputId}\n type={revealed ? 'text' : 'password'}\n value={value}\n defaultValue={defaultValue}\n disabled={disabled}\n onChange={(event) => onValueChange?.(event.target.value)}\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 disabled:cursor-not-allowed',\n )}\n />\n\n <button\n type=\"button\"\n // Not a submit button, and not in the tab order ahead of the field it\n // belongs to - it sits after the input, which is where Tab reaches it.\n onClick={() => setRevealed((was) => !was)}\n disabled={disabled}\n aria-label={revealed ? hideLabel : showLabel}\n aria-pressed={revealed}\n aria-controls={props.id ?? inputId}\n className={cn(\n 'flex w-9 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n 'disabled:pointer-events-none disabled:opacity-50',\n )}\n >\n {revealed ? (\n /* An eye with a stroke through it: hiding is the action offered\n * while the password is visible. */\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M4 4l12 12M8.5 8.7a2 2 0 002.8 2.8M6.3 6.4C4.4 7.5 3 9.2 2.5 10c1.2 2.2 4 5 7.5 5 1.3 0 2.5-.4 3.5-1M9 5.1c.3 0 .7-.1 1-.1 3.5 0 6.3 2.8 7.5 5-.3.5-.8 1.3-1.6 2.1\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n />\n </svg>\n ) : (\n <svg viewBox=\"0 0 20 20\" className=\"size-4\" fill=\"none\" aria-hidden>\n <path\n d=\"M2.5 10C3.7 7.8 6.5 5 10 5s6.3 2.8 7.5 5c-1.2 2.2-4 5-7.5 5s-6.3-2.8-7.5-5z\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n />\n <circle cx=\"10\" cy=\"10\" r=\"2.2\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n </svg>\n )}\n </button>\n </div>\n )\n}\n"
|
|
1618
1639
|
}
|
|
1619
1640
|
]
|
|
1620
1641
|
},
|
|
@@ -1626,7 +1647,7 @@
|
|
|
1626
1647
|
"dependencies": [
|
|
1627
1648
|
"@base-ui/react",
|
|
1628
1649
|
"class-variance-authority",
|
|
1629
|
-
"dowel-ui@^0.
|
|
1650
|
+
"dowel-ui@^0.37.0"
|
|
1630
1651
|
],
|
|
1631
1652
|
"registryDependencies": [
|
|
1632
1653
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1648,7 +1669,7 @@
|
|
|
1648
1669
|
"dependencies": [
|
|
1649
1670
|
"@base-ui/react",
|
|
1650
1671
|
"class-variance-authority",
|
|
1651
|
-
"dowel-ui@^0.
|
|
1672
|
+
"dowel-ui@^0.37.0"
|
|
1652
1673
|
],
|
|
1653
1674
|
"registryDependencies": [
|
|
1654
1675
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1668,7 +1689,7 @@
|
|
|
1668
1689
|
"title": "ProductMark",
|
|
1669
1690
|
"description": "The product-mark primitive.",
|
|
1670
1691
|
"dependencies": [
|
|
1671
|
-
"dowel-ui@^0.
|
|
1692
|
+
"dowel-ui@^0.37.0"
|
|
1672
1693
|
],
|
|
1673
1694
|
"registryDependencies": [],
|
|
1674
1695
|
"files": [
|
|
@@ -1686,7 +1707,7 @@
|
|
|
1686
1707
|
"title": "ProductSwitcher",
|
|
1687
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.",
|
|
1688
1709
|
"dependencies": [
|
|
1689
|
-
"dowel-ui@^0.
|
|
1710
|
+
"dowel-ui@^0.37.0"
|
|
1690
1711
|
],
|
|
1691
1712
|
"registryDependencies": [
|
|
1692
1713
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
@@ -1709,7 +1730,7 @@
|
|
|
1709
1730
|
"dependencies": [
|
|
1710
1731
|
"@base-ui/react",
|
|
1711
1732
|
"class-variance-authority",
|
|
1712
|
-
"dowel-ui@^0.
|
|
1733
|
+
"dowel-ui@^0.37.0"
|
|
1713
1734
|
],
|
|
1714
1735
|
"registryDependencies": [],
|
|
1715
1736
|
"files": [
|
|
@@ -1749,7 +1770,7 @@
|
|
|
1749
1770
|
"dependencies": [
|
|
1750
1771
|
"@base-ui/react",
|
|
1751
1772
|
"class-variance-authority",
|
|
1752
|
-
"dowel-ui@^0.
|
|
1773
|
+
"dowel-ui@^0.37.0"
|
|
1753
1774
|
],
|
|
1754
1775
|
"registryDependencies": [],
|
|
1755
1776
|
"files": [
|
|
@@ -1767,7 +1788,7 @@
|
|
|
1767
1788
|
"title": "RatingScale",
|
|
1768
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\".",
|
|
1769
1790
|
"dependencies": [
|
|
1770
|
-
"dowel-ui@^0.
|
|
1791
|
+
"dowel-ui@^0.37.0"
|
|
1771
1792
|
],
|
|
1772
1793
|
"registryDependencies": [],
|
|
1773
1794
|
"files": [
|
|
@@ -1785,7 +1806,7 @@
|
|
|
1785
1806
|
"title": "RelativeTime",
|
|
1786
1807
|
"description": "The relative-time primitive.",
|
|
1787
1808
|
"dependencies": [
|
|
1788
|
-
"dowel-ui@^0.
|
|
1809
|
+
"dowel-ui@^0.37.0"
|
|
1789
1810
|
],
|
|
1790
1811
|
"registryDependencies": [],
|
|
1791
1812
|
"files": [
|
|
@@ -1803,7 +1824,7 @@
|
|
|
1803
1824
|
"title": "ReorderableList",
|
|
1804
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.",
|
|
1805
1826
|
"dependencies": [
|
|
1806
|
-
"dowel-ui@^0.
|
|
1827
|
+
"dowel-ui@^0.37.0"
|
|
1807
1828
|
],
|
|
1808
1829
|
"registryDependencies": [],
|
|
1809
1830
|
"files": [
|
|
@@ -1821,7 +1842,7 @@
|
|
|
1821
1842
|
"title": "SaveState",
|
|
1822
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.",
|
|
1823
1844
|
"dependencies": [
|
|
1824
|
-
"dowel-ui@^0.
|
|
1845
|
+
"dowel-ui@^0.37.0"
|
|
1825
1846
|
],
|
|
1826
1847
|
"registryDependencies": [
|
|
1827
1848
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1835,6 +1856,73 @@
|
|
|
1835
1856
|
}
|
|
1836
1857
|
]
|
|
1837
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
|
+
},
|
|
1838
1926
|
{
|
|
1839
1927
|
"name": "scroll-area",
|
|
1840
1928
|
"type": "registry:ui",
|
|
@@ -1843,7 +1931,7 @@
|
|
|
1843
1931
|
"dependencies": [
|
|
1844
1932
|
"@base-ui/react",
|
|
1845
1933
|
"class-variance-authority",
|
|
1846
|
-
"dowel-ui@^0.
|
|
1934
|
+
"dowel-ui@^0.37.0"
|
|
1847
1935
|
],
|
|
1848
1936
|
"registryDependencies": [],
|
|
1849
1937
|
"files": [
|
|
@@ -1861,7 +1949,7 @@
|
|
|
1861
1949
|
"title": "SearchField",
|
|
1862
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.",
|
|
1863
1951
|
"dependencies": [
|
|
1864
|
-
"dowel-ui@^0.
|
|
1952
|
+
"dowel-ui@^0.37.0"
|
|
1865
1953
|
],
|
|
1866
1954
|
"registryDependencies": [
|
|
1867
1955
|
"https://lacodda.github.io/dowel/r/commands.json",
|
|
@@ -1884,7 +1972,7 @@
|
|
|
1884
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.",
|
|
1885
1973
|
"dependencies": [
|
|
1886
1974
|
"@base-ui/react",
|
|
1887
|
-
"dowel-ui@^0.
|
|
1975
|
+
"dowel-ui@^0.37.0"
|
|
1888
1976
|
],
|
|
1889
1977
|
"registryDependencies": [],
|
|
1890
1978
|
"files": [
|
|
@@ -1892,7 +1980,7 @@
|
|
|
1892
1980
|
"path": "ui/section-nav.tsx",
|
|
1893
1981
|
"target": "@ui/section-nav.tsx",
|
|
1894
1982
|
"type": "registry:ui",
|
|
1895
|
-
"content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A line under the label saying what the section holds - \"Theme,\n * language, start screen\" - so a reader chooses a section by what is in\n * it rather than by guessing from one word. At most two lines; past that\n * it is cut, because a column of rows that grow unevenly stops being a\n * list. */\n description?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n /** Keep the caption as the landmark's name and take it off the screen. For\n * a screen whose own title already says it - a settings screen headed\n * \"Settings\" with a list captioned \"Settings\" names itself twice, which is\n * what kilna's did. */\n labelHidden?: boolean\n}\n\nexport function SectionNav({\n label,\n items,\n activeId,\n render,\n onSelect,\n labelHidden = false,\n className,\n ...props\n}: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className={cn('caption px-2.5 pb-2', labelHidden && 'sr-only')}>\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n // The whole label on hover, since the row may have cut it. Only a\n // string can be a title; a label that is an element says itself.\n title: typeof item.label === 'string' ? item.label : undefined,\n className: cn(\n 'flex w-full gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n item.description === undefined ? 'items-center' : 'items-start',\n 'hover:bg-soft hover:text-text [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {/* One line, truncated, like a NavRail entry. A section column is a\n fixed width, and a label that wraps makes one row taller than\n every other - which in a two-language product happens to one\n section and not the rest, so the column reads as ragged and the\n line height stops meaning anything. `min-w-0` because a flex\n child will not shrink below its content without it. */}\n {item.description === undefined ? (\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n ) : (\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className={cn('truncate', active && 'font-semibold')}>{item.label}</span>\n <span className=\"line-clamp-2 text-2xs text-
|
|
1983
|
+
"content": "import { useId, type HTMLAttributes, type ReactNode } from 'react'\nimport { useRender } from '@base-ui/react/use-render'\nimport { cn } from 'dowel-ui'\n\n/*\n * The left-hand list of a screen with sections, and the heading each opens.\n *\n * A settings screen is the usual case: five or six sections, each its own\n * address so it can be linked to and the back button walks between them,\n * listed down the left with the current one tinted. Every product draws the\n * same column, and every product draws the active row a little differently -\n * which is exactly the drift a shared list exists to stop.\n *\n * The rows are the product's links, not the component's. `render` takes the\n * item and answers with the element to draw it as - a router's `NavLink`, an\n * `<a>`, whatever the product navigates with - and the component puts the\n * row's clothes, its icon and its `aria-current` on it. Without `render` a\n * row is a button and `onSelect` says which one was pressed, for a screen\n * whose sections are state rather than routes.\n *\n * `SectionHeading` is the other half: the title and the one-line hint above\n * a section's body, so the column and the page it opens are set in the same\n * type.\n */\n\nexport interface SectionNavItem {\n id: string\n label: ReactNode\n icon?: ReactNode\n /** A line under the label saying what the section holds - \"Theme,\n * language, start screen\" - so a reader chooses a section by what is in\n * it rather than by guessing from one word. At most two lines; past that\n * it is cut, because a column of rows that grow unevenly stops being a\n * list. */\n description?: ReactNode\n}\n\nexport interface SectionNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children' | 'onSelect'> {\n /** What the list is called: the caption above it, and the name a screen\n * reader gives the landmark. */\n label: string\n items: readonly SectionNavItem[]\n /** Which item is the current page. */\n activeId?: string\n /** The element a row is drawn as - `render={(item) => <NavLink to={…} />}`.\n * The row's props are merged onto it, the way Button takes a `render`. */\n render?: (item: SectionNavItem) => useRender.RenderProp\n /** Pressed, whatever the row is drawn as. */\n onSelect?: (id: string) => void\n /** Keep the caption as the landmark's name and take it off the screen. For\n * a screen whose own title already says it - a settings screen headed\n * \"Settings\" with a list captioned \"Settings\" names itself twice, which is\n * what kilna's did. */\n labelHidden?: boolean\n}\n\nexport function SectionNav({\n label,\n items,\n activeId,\n render,\n onSelect,\n labelHidden = false,\n className,\n ...props\n}: SectionNavProps) {\n const captionId = useId()\n return (\n <nav aria-labelledby={captionId} className={cn('flex flex-col gap-0.5', className)} {...props}>\n <h2 id={captionId} className={cn('caption px-2.5 pb-2', labelHidden && 'sr-only')}>\n {label}\n </h2>\n {items.map((item) => (\n <SectionNavRow\n key={item.id}\n item={item}\n active={item.id === activeId}\n render={render?.(item)}\n onSelect={onSelect}\n />\n ))}\n </nav>\n )\n}\n\nfunction SectionNavRow({\n item,\n active,\n render,\n onSelect,\n}: {\n item: SectionNavItem\n active: boolean\n render?: useRender.RenderProp\n onSelect?: (id: string) => void\n}) {\n return useRender({\n render,\n defaultTagName: 'button',\n props: {\n ...(render === undefined ? { type: 'button' } : {}),\n // Named as the current page for a screen reader, which cannot see that\n // it is the tinted one.\n 'aria-current': active ? 'page' : undefined,\n onClick: () => onSelect?.(item.id),\n // The whole label on hover, since the row may have cut it. Only a\n // string can be a title; a label that is an element says itself.\n title: typeof item.label === 'string' ? item.label : undefined,\n className: cn(\n 'flex w-full gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-dim no-underline transition-colors',\n item.description === undefined ? 'items-center' : 'items-start',\n 'hover:bg-soft hover:text-text [&_svg:not([class*=size-])]:size-4 [&_svg]:shrink-0',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n active && 'bg-accent-soft text-text [&_svg]:text-accent',\n ),\n children: (\n <>\n {item.icon ? <span aria-hidden className=\"contents\">{item.icon}</span> : null}\n {/* One line, truncated, like a NavRail entry. A section column is a\n fixed width, and a label that wraps makes one row taller than\n every other - which in a two-language product happens to one\n section and not the rest, so the column reads as ragged and the\n line height stops meaning anything. `min-w-0` because a flex\n child will not shrink below its content without it. */}\n {item.description === undefined ? (\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n ) : (\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className={cn('truncate', active && 'font-semibold')}>{item.label}</span>\n {/* `dim` at this size: `faint` measured below AA at 10px in\n both themes (the same finding as ListRow's notes). */}\n <span className=\"line-clamp-2 text-2xs text-dim\">{item.description}</span>\n </span>\n )}\n </>\n ),\n },\n })\n}\n\nexport interface SectionHeadingProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n title: ReactNode\n description?: ReactNode\n}\n\n/** The title of the open section and the line under it. */\nexport function SectionHeading({ title, description, className, ...props }: SectionHeadingProps) {\n return (\n <div className={cn('mb-4 flex flex-col gap-1', className)} {...props}>\n <h2 className=\"text-base font-semibold text-text\">{title}</h2>\n {description ? <p className=\"text-sm text-dim\">{description}</p> : null}\n </div>\n )\n}\n"
|
|
1896
1984
|
}
|
|
1897
1985
|
]
|
|
1898
1986
|
},
|
|
@@ -1903,7 +1991,7 @@
|
|
|
1903
1991
|
"description": "Two to five options, one of them chosen, all of them in view - a radio group in a segment's clothes.",
|
|
1904
1992
|
"dependencies": [
|
|
1905
1993
|
"@base-ui/react",
|
|
1906
|
-
"dowel-ui@^0.
|
|
1994
|
+
"dowel-ui@^0.37.0"
|
|
1907
1995
|
],
|
|
1908
1996
|
"registryDependencies": [],
|
|
1909
1997
|
"files": [
|
|
@@ -1923,7 +2011,7 @@
|
|
|
1923
2011
|
"dependencies": [
|
|
1924
2012
|
"@base-ui/react",
|
|
1925
2013
|
"class-variance-authority",
|
|
1926
|
-
"dowel-ui@^0.
|
|
2014
|
+
"dowel-ui@^0.37.0"
|
|
1927
2015
|
],
|
|
1928
2016
|
"registryDependencies": [
|
|
1929
2017
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1960,7 +2048,7 @@
|
|
|
1960
2048
|
"title": "ShortcutsDialog",
|
|
1961
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.",
|
|
1962
2050
|
"dependencies": [
|
|
1963
|
-
"dowel-ui@^0.
|
|
2051
|
+
"dowel-ui@^0.37.0"
|
|
1964
2052
|
],
|
|
1965
2053
|
"registryDependencies": [
|
|
1966
2054
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1983,7 +2071,7 @@
|
|
|
1983
2071
|
"title": "SkeletonOf",
|
|
1984
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.",
|
|
1985
2073
|
"dependencies": [
|
|
1986
|
-
"dowel-ui@^0.
|
|
2074
|
+
"dowel-ui@^0.37.0"
|
|
1987
2075
|
],
|
|
1988
2076
|
"registryDependencies": [],
|
|
1989
2077
|
"files": [
|
|
@@ -1991,7 +2079,7 @@
|
|
|
1991
2079
|
"path": "ui/skeleton-of.tsx",
|
|
1992
2080
|
"target": "@ui/skeleton-of.tsx",
|
|
1993
2081
|
"type": "registry:ui",
|
|
1994
|
-
"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"
|
|
1995
2083
|
}
|
|
1996
2084
|
]
|
|
1997
2085
|
},
|
|
@@ -2001,7 +2089,7 @@
|
|
|
2001
2089
|
"title": "Skeleton",
|
|
2002
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.",
|
|
2003
2091
|
"dependencies": [
|
|
2004
|
-
"dowel-ui@^0.
|
|
2092
|
+
"dowel-ui@^0.37.0"
|
|
2005
2093
|
],
|
|
2006
2094
|
"registryDependencies": [],
|
|
2007
2095
|
"files": [
|
|
@@ -2009,7 +2097,7 @@
|
|
|
2009
2097
|
"path": "ui/skeleton.tsx",
|
|
2010
2098
|
"target": "@ui/skeleton.tsx",
|
|
2011
2099
|
"type": "registry:ui",
|
|
2012
|
-
"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"
|
|
2013
2101
|
}
|
|
2014
2102
|
]
|
|
2015
2103
|
},
|
|
@@ -2020,7 +2108,7 @@
|
|
|
2020
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\".",
|
|
2021
2109
|
"dependencies": [
|
|
2022
2110
|
"@base-ui/react",
|
|
2023
|
-
"dowel-ui@^0.
|
|
2111
|
+
"dowel-ui@^0.37.0"
|
|
2024
2112
|
],
|
|
2025
2113
|
"registryDependencies": [],
|
|
2026
2114
|
"files": [
|
|
@@ -2039,7 +2127,7 @@
|
|
|
2039
2127
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
2040
2128
|
"dependencies": [
|
|
2041
2129
|
"class-variance-authority",
|
|
2042
|
-
"dowel-ui@^0.
|
|
2130
|
+
"dowel-ui@^0.37.0"
|
|
2043
2131
|
],
|
|
2044
2132
|
"registryDependencies": [],
|
|
2045
2133
|
"files": [
|
|
@@ -2058,7 +2146,7 @@
|
|
|
2058
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.",
|
|
2059
2147
|
"dependencies": [
|
|
2060
2148
|
"class-variance-authority",
|
|
2061
|
-
"dowel-ui@^0.
|
|
2149
|
+
"dowel-ui@^0.37.0"
|
|
2062
2150
|
],
|
|
2063
2151
|
"registryDependencies": [],
|
|
2064
2152
|
"files": [
|
|
@@ -2066,7 +2154,7 @@
|
|
|
2066
2154
|
"path": "ui/spinner.tsx",
|
|
2067
2155
|
"target": "@ui/spinner.tsx",
|
|
2068
2156
|
"type": "registry:ui",
|
|
2069
|
-
"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"
|
|
2070
2158
|
}
|
|
2071
2159
|
]
|
|
2072
2160
|
},
|
|
@@ -2076,7 +2164,7 @@
|
|
|
2076
2164
|
"title": "Splash",
|
|
2077
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.",
|
|
2078
2166
|
"dependencies": [
|
|
2079
|
-
"dowel-ui@^0.
|
|
2167
|
+
"dowel-ui@^0.37.0"
|
|
2080
2168
|
],
|
|
2081
2169
|
"registryDependencies": [],
|
|
2082
2170
|
"files": [
|
|
@@ -2095,7 +2183,7 @@
|
|
|
2095
2183
|
"description": "The splitter primitive.",
|
|
2096
2184
|
"dependencies": [
|
|
2097
2185
|
"class-variance-authority",
|
|
2098
|
-
"dowel-ui@^0.
|
|
2186
|
+
"dowel-ui@^0.37.0"
|
|
2099
2187
|
],
|
|
2100
2188
|
"registryDependencies": [],
|
|
2101
2189
|
"files": [
|
|
@@ -2114,7 +2202,7 @@
|
|
|
2114
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.",
|
|
2115
2203
|
"dependencies": [
|
|
2116
2204
|
"class-variance-authority",
|
|
2117
|
-
"dowel-ui@^0.
|
|
2205
|
+
"dowel-ui@^0.37.0"
|
|
2118
2206
|
],
|
|
2119
2207
|
"registryDependencies": [],
|
|
2120
2208
|
"files": [
|
|
@@ -2133,7 +2221,7 @@
|
|
|
2133
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.",
|
|
2134
2222
|
"dependencies": [
|
|
2135
2223
|
"class-variance-authority",
|
|
2136
|
-
"dowel-ui@^0.
|
|
2224
|
+
"dowel-ui@^0.37.0"
|
|
2137
2225
|
],
|
|
2138
2226
|
"registryDependencies": [],
|
|
2139
2227
|
"files": [
|
|
@@ -2152,7 +2240,7 @@
|
|
|
2152
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.",
|
|
2153
2241
|
"dependencies": [
|
|
2154
2242
|
"class-variance-authority",
|
|
2155
|
-
"dowel-ui@^0.
|
|
2243
|
+
"dowel-ui@^0.37.0"
|
|
2156
2244
|
],
|
|
2157
2245
|
"registryDependencies": [],
|
|
2158
2246
|
"files": [
|
|
@@ -2171,7 +2259,7 @@
|
|
|
2171
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.",
|
|
2172
2260
|
"dependencies": [
|
|
2173
2261
|
"@base-ui/react",
|
|
2174
|
-
"dowel-ui@^0.
|
|
2262
|
+
"dowel-ui@^0.37.0"
|
|
2175
2263
|
],
|
|
2176
2264
|
"registryDependencies": [],
|
|
2177
2265
|
"files": [
|
|
@@ -2206,7 +2294,7 @@
|
|
|
2206
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.",
|
|
2207
2295
|
"dependencies": [
|
|
2208
2296
|
"class-variance-authority",
|
|
2209
|
-
"dowel-ui@^0.
|
|
2297
|
+
"dowel-ui@^0.37.0"
|
|
2210
2298
|
],
|
|
2211
2299
|
"registryDependencies": [
|
|
2212
2300
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -2228,7 +2316,7 @@
|
|
|
2228
2316
|
"dependencies": [
|
|
2229
2317
|
"@base-ui/react",
|
|
2230
2318
|
"class-variance-authority",
|
|
2231
|
-
"dowel-ui@^0.
|
|
2319
|
+
"dowel-ui@^0.37.0"
|
|
2232
2320
|
],
|
|
2233
2321
|
"registryDependencies": [],
|
|
2234
2322
|
"files": [
|
|
@@ -2247,7 +2335,7 @@
|
|
|
2247
2335
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
2248
2336
|
"dependencies": [
|
|
2249
2337
|
"class-variance-authority",
|
|
2250
|
-
"dowel-ui@^0.
|
|
2338
|
+
"dowel-ui@^0.37.0"
|
|
2251
2339
|
],
|
|
2252
2340
|
"registryDependencies": [
|
|
2253
2341
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -2258,7 +2346,7 @@
|
|
|
2258
2346
|
"path": "ui/tag-input.tsx",
|
|
2259
2347
|
"target": "@ui/tag-input.tsx",
|
|
2260
2348
|
"type": "registry:ui",
|
|
2261
|
-
"content": "import { useCallback, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Chip } from './chip'\n\n/*\n * TagInput.\n *\n * Free text turned into a list: type a word, press Enter, it becomes a chip.\n * This is the field for labels, keywords, recipients - anywhere the set of\n * values is not known in advance, which is exactly what separates it from\n * `Combobox multiple`. Choosing from a list is that component's job; this one\n * is for values that do not exist until someone types them.\n *\n * Base UI has no part for this, and the reason is worth stating: its Combobox\n * chips are for values chosen from a collection, and \"create the thing I just\n * typed\" is a decision about the product's data, not about the widget. So the\n * behaviour here is written, and written narrowly.\n *\n * What it does, and why each one is not optional:\n *\n * - **Enter commits.** The obvious one, and the reason it is a field at all.\n * - **Backspace on an empty input removes the last tag.** Without it the only\n * way back is the mouse, and a list built by typing should be unbuildable\n * by typing too.\n * - **Blur commits what was typed.** A word left in the box when the reader\n * clicks Save is a word they believe they entered. Losing it silently is\n * the single commonest complaint about fields of this shape.\n * - **Duplicates are refused, not stacked.** A tag list is a set.\n * - **Whitespace is trimmed and empties dropped**, so Enter on a blank input\n * does nothing rather than adding a tag nobody can see.\n *\n * What it deliberately does not do: validate. What makes a tag acceptable -\n * length, charset, whether it must already exist - is the product's rule, and\n * a component that guessed would be wrong in both directions. Reject a value\n * by not putting it in `value`.\n */\n\nexport const tagInputVariants = cva(\n cn(\n fieldClasses,\n 'flex flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n ),\n {\n variants: {\n size: {\n sm: 'min-h-8 text-xs',\n md: 'min-h-9',\n lg: 'min-h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/* The box inside the box.\n *\n * `min-h-*` on the container sets a floor; the input inside it has a height of\n * its own, and that is what a reader actually sees. A size that moved only the\n * container moved nothing - measured, `sm` and `md` both came out 38px tall,\n * because the input was `h-7` in all three and its padding decided the rest.\n *\n * `tailwind-merge` does not rescue this either: `h-7` and `min-h-8` are not\n * the same property, so both survive and the fixed height wins. The two have\n * to be declared together.\n */\nconst tagInputBoxVariants = cva(\n 'w-auto min-w-24 flex-1 bg-transparent px-1 text-text outline-none placeholder:text-faint',\n {\n variants: {\n size: {\n sm: 'h-6 text-xs',\n md: 'h-7 text-sm',\n lg: 'h-8 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\nexport interface TagInputProps extends VariantProps<typeof tagInputVariants> {\n /** The tags. Controlled: the product owns the list. */\n value: string[]\n /** Called with the next list. */\n onValueChange: (value: string[]) => void\n /** Placeholder for the text box, shown when there is room for it. */\n placeholder?: string\n /** Names the field. Required - a box of chips with no label names nothing. */\n 'aria-label'?: string\n /** Or points at a label element, when there is a visible one. */\n 'aria-labelledby'?: string\n /** How a tag's remove button is named, for a screen reader. Takes the tag,\n * so the product can say \"Remove documentation\" in its own language.\n *\n * Required, like `Chip`'s own: a remove button exists only when there is a\n * word for it, and a word this component invented would ship in English to\n * every reader who does not read English. */\n removeLabel: (tag: string) => string\n /** Cap on how many tags may be added. */\n max?: number\n disabled?: boolean\n /** Drawn after the input, inside the box - a counter, a hint. */\n children?: ReactNode\n className?: string\n id?: string\n}\n\nexport function TagInput({\n value,\n onValueChange,\n placeholder,\n removeLabel,\n max,\n disabled,\n size,\n children,\n className,\n id,\n ...aria\n}: TagInputProps) {\n const [draft, setDraft] = useState('')\n\n const full = max !== undefined && value.length >= max\n\n const commit = useCallback(\n (raw: string) => {\n const tag = raw.trim()\n // Empty, duplicate, or past the cap: nothing to add. The draft is\n // cleared either way, so Enter always leaves the box ready for the next\n // word rather than leaving a rejected one sitting there.\n if (tag && !value.includes(tag) && !full) onValueChange([...value, tag])\n setDraft('')\n },\n [full, onValueChange, value],\n )\n\n const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n // Only when there is something to commit: otherwise Enter belongs to the\n // form around this, and swallowing it would break submitting by keyboard.\n if (draft.trim()) {\n event.preventDefault()\n commit(draft)\n }\n return\n }\n if (event.key === 'Backspace' && draft === '' && value.length > 0) {\n onValueChange(value.slice(0, -1))\n }\n }\n\n return (\n <div\n className={cn(tagInputVariants({ size }), disabled && 'pointer-events-none opacity-50', className)}\n >\n {value.map((tag) => (\n <Chip\n key={tag}\n removeLabel={removeLabel(tag)}\n onRemove={() => onValueChange(value.filter((entry) => entry !== tag))}\n >\n {tag}\n </Chip>\n ))}\n <input\n id={id}\n value={draft}\n disabled={disabled}\n // The list is the value; the box is how you add to it. Announcing the\n // box as the field would name the wrong thing, so the label goes here\n // where a screen reader meets it.\n {...aria}\n placeholder={full ? undefined : placeholder}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={onKeyDown}\n // A word typed and not committed is a word the reader thinks they\n // entered. Blur is where that belief meets the form's Save button.\n onBlur={() => commit(draft)}\n className={tagInputBoxVariants({ size })}\n />\n {children}\n </div>\n )\n}\n"
|
|
2349
|
+
"content": "import { useCallback, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { fieldClasses } from './input'\nimport { Chip } from './chip'\n\n/*\n * TagInput.\n *\n * Free text turned into a list: type a word, press Enter, it becomes a chip.\n * This is the field for labels, keywords, recipients - anywhere the set of\n * values is not known in advance, which is exactly what separates it from\n * `Combobox multiple`. Choosing from a list is that component's job; this one\n * is for values that do not exist until someone types them.\n *\n * Base UI has no part for this, and the reason is worth stating: its Combobox\n * chips are for values chosen from a collection, and \"create the thing I just\n * typed\" is a decision about the product's data, not about the widget. So the\n * behaviour here is written, and written narrowly.\n *\n * What it does, and why each one is not optional:\n *\n * - **Enter commits.** The obvious one, and the reason it is a field at all.\n * - **Backspace on an empty input removes the last tag.** Without it the only\n * way back is the mouse, and a list built by typing should be unbuildable\n * by typing too.\n * - **Blur commits what was typed.** A word left in the box when the reader\n * clicks Save is a word they believe they entered. Losing it silently is\n * the single commonest complaint about fields of this shape.\n * - **Duplicates are refused, not stacked.** A tag list is a set.\n * - **Whitespace is trimmed and empties dropped**, so Enter on a blank input\n * does nothing rather than adding a tag nobody can see.\n *\n * What it deliberately does not do: validate. What makes a tag acceptable -\n * length, charset, whether it must already exist - is the product's rule, and\n * a component that guessed would be wrong in both directions. Reject a value\n * by not putting it in `value`.\n */\n\nexport const tagInputVariants = cva(\n cn(\n fieldClasses,\n 'flex flex-wrap items-center gap-1 py-1',\n 'focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-accent',\n ),\n {\n variants: {\n size: {\n sm: 'min-h-8 text-xs',\n md: 'min-h-9',\n lg: 'min-h-10 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/* The box inside the box.\n *\n * `min-h-*` on the container sets a floor; the input inside it has a height of\n * its own, and that is what a reader actually sees. A size that moved only the\n * container moved nothing - measured, `sm` and `md` both came out 38px tall,\n * because the input was `h-7` in all three and its padding decided the rest.\n *\n * `tailwind-merge` does not rescue this either: `h-7` and `min-h-8` are not\n * the same property, so both survive and the fixed height wins. The two have\n * to be declared together.\n */\nconst tagInputBoxVariants = cva(\n // 16px under a finger, for the reason `fieldClasses` gives.\n 'w-auto min-w-24 flex-1 bg-transparent px-1 pointer-coarse:text-lg text-text outline-none placeholder:text-faint',\n {\n variants: {\n size: {\n sm: 'h-6 text-xs',\n md: 'h-7 text-sm',\n lg: 'h-8 text-base',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\nexport interface TagInputProps extends VariantProps<typeof tagInputVariants> {\n /** The tags. Controlled: the product owns the list. */\n value: string[]\n /** Called with the next list. */\n onValueChange: (value: string[]) => void\n /** Placeholder for the text box, shown when there is room for it. */\n placeholder?: string\n /** Names the field. Required - a box of chips with no label names nothing. */\n 'aria-label'?: string\n /** Or points at a label element, when there is a visible one. */\n 'aria-labelledby'?: string\n /** How a tag's remove button is named, for a screen reader. Takes the tag,\n * so the product can say \"Remove documentation\" in its own language.\n *\n * Required, like `Chip`'s own: a remove button exists only when there is a\n * word for it, and a word this component invented would ship in English to\n * every reader who does not read English. */\n removeLabel: (tag: string) => string\n /** Cap on how many tags may be added. */\n max?: number\n disabled?: boolean\n /** Drawn after the input, inside the box - a counter, a hint. */\n children?: ReactNode\n className?: string\n id?: string\n}\n\nexport function TagInput({\n value,\n onValueChange,\n placeholder,\n removeLabel,\n max,\n disabled,\n size,\n children,\n className,\n id,\n ...aria\n}: TagInputProps) {\n const [draft, setDraft] = useState('')\n\n const full = max !== undefined && value.length >= max\n\n const commit = useCallback(\n (raw: string) => {\n const tag = raw.trim()\n // Empty, duplicate, or past the cap: nothing to add. The draft is\n // cleared either way, so Enter always leaves the box ready for the next\n // word rather than leaving a rejected one sitting there.\n if (tag && !value.includes(tag) && !full) onValueChange([...value, tag])\n setDraft('')\n },\n [full, onValueChange, value],\n )\n\n const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n // Only when there is something to commit: otherwise Enter belongs to the\n // form around this, and swallowing it would break submitting by keyboard.\n if (draft.trim()) {\n event.preventDefault()\n commit(draft)\n }\n return\n }\n if (event.key === 'Backspace' && draft === '' && value.length > 0) {\n onValueChange(value.slice(0, -1))\n }\n }\n\n return (\n <div\n className={cn(tagInputVariants({ size }), disabled && 'pointer-events-none opacity-50', className)}\n >\n {value.map((tag) => (\n <Chip\n key={tag}\n removeLabel={removeLabel(tag)}\n onRemove={() => onValueChange(value.filter((entry) => entry !== tag))}\n >\n {tag}\n </Chip>\n ))}\n <input\n id={id}\n value={draft}\n disabled={disabled}\n // The list is the value; the box is how you add to it. Announcing the\n // box as the field would name the wrong thing, so the label goes here\n // where a screen reader meets it.\n {...aria}\n placeholder={full ? undefined : placeholder}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={onKeyDown}\n // A word typed and not committed is a word the reader thinks they\n // entered. Blur is where that belief meets the form's Save button.\n onBlur={() => commit(draft)}\n className={tagInputBoxVariants({ size })}\n />\n {children}\n </div>\n )\n}\n"
|
|
2262
2350
|
}
|
|
2263
2351
|
]
|
|
2264
2352
|
},
|
|
@@ -2268,7 +2356,7 @@
|
|
|
2268
2356
|
"title": "Textarea",
|
|
2269
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.",
|
|
2270
2358
|
"dependencies": [
|
|
2271
|
-
"dowel-ui@^0.
|
|
2359
|
+
"dowel-ui@^0.37.0"
|
|
2272
2360
|
],
|
|
2273
2361
|
"registryDependencies": [
|
|
2274
2362
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2289,7 +2377,7 @@
|
|
|
2289
2377
|
"description": "The tier primitive.",
|
|
2290
2378
|
"dependencies": [
|
|
2291
2379
|
"class-variance-authority",
|
|
2292
|
-
"dowel-ui@^0.
|
|
2380
|
+
"dowel-ui@^0.37.0"
|
|
2293
2381
|
],
|
|
2294
2382
|
"registryDependencies": [],
|
|
2295
2383
|
"files": [
|
|
@@ -2307,7 +2395,7 @@
|
|
|
2307
2395
|
"title": "TimeField",
|
|
2308
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`.",
|
|
2309
2397
|
"dependencies": [
|
|
2310
|
-
"dowel-ui@^0.
|
|
2398
|
+
"dowel-ui@^0.37.0"
|
|
2311
2399
|
],
|
|
2312
2400
|
"registryDependencies": [
|
|
2313
2401
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2328,7 +2416,7 @@
|
|
|
2328
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.",
|
|
2329
2417
|
"dependencies": [
|
|
2330
2418
|
"class-variance-authority",
|
|
2331
|
-
"dowel-ui@^0.
|
|
2419
|
+
"dowel-ui@^0.37.0"
|
|
2332
2420
|
],
|
|
2333
2421
|
"registryDependencies": [],
|
|
2334
2422
|
"files": [
|
|
@@ -2348,7 +2436,7 @@
|
|
|
2348
2436
|
"dependencies": [
|
|
2349
2437
|
"@base-ui/react",
|
|
2350
2438
|
"class-variance-authority",
|
|
2351
|
-
"dowel-ui@^0.
|
|
2439
|
+
"dowel-ui@^0.37.0"
|
|
2352
2440
|
],
|
|
2353
2441
|
"registryDependencies": [],
|
|
2354
2442
|
"files": [
|
|
@@ -2368,7 +2456,7 @@
|
|
|
2368
2456
|
"dependencies": [
|
|
2369
2457
|
"@base-ui/react",
|
|
2370
2458
|
"class-variance-authority",
|
|
2371
|
-
"dowel-ui@^0.
|
|
2459
|
+
"dowel-ui@^0.37.0"
|
|
2372
2460
|
],
|
|
2373
2461
|
"registryDependencies": [
|
|
2374
2462
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -2405,7 +2493,7 @@
|
|
|
2405
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.",
|
|
2406
2494
|
"dependencies": [
|
|
2407
2495
|
"class-variance-authority",
|
|
2408
|
-
"dowel-ui@^0.
|
|
2496
|
+
"dowel-ui@^0.37.0"
|
|
2409
2497
|
],
|
|
2410
2498
|
"registryDependencies": [
|
|
2411
2499
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -2441,7 +2529,7 @@
|
|
|
2441
2529
|
"title": "TreeView",
|
|
2442
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.",
|
|
2443
2531
|
"dependencies": [
|
|
2444
|
-
"dowel-ui@^0.
|
|
2532
|
+
"dowel-ui@^0.37.0"
|
|
2445
2533
|
],
|
|
2446
2534
|
"registryDependencies": [
|
|
2447
2535
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -2461,7 +2549,7 @@
|
|
|
2461
2549
|
"title": "Truncate",
|
|
2462
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.",
|
|
2463
2551
|
"dependencies": [
|
|
2464
|
-
"dowel-ui@^0.
|
|
2552
|
+
"dowel-ui@^0.37.0"
|
|
2465
2553
|
],
|
|
2466
2554
|
"registryDependencies": [],
|
|
2467
2555
|
"files": [
|
|
@@ -2479,7 +2567,7 @@
|
|
|
2479
2567
|
"title": "VirtualList",
|
|
2480
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.",
|
|
2481
2569
|
"dependencies": [
|
|
2482
|
-
"dowel-ui@^0.
|
|
2570
|
+
"dowel-ui@^0.37.0"
|
|
2483
2571
|
],
|
|
2484
2572
|
"registryDependencies": [],
|
|
2485
2573
|
"files": [
|
|
@@ -2498,7 +2586,7 @@
|
|
|
2498
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.",
|
|
2499
2587
|
"dependencies": [
|
|
2500
2588
|
"@tauri-apps/api",
|
|
2501
|
-
"dowel-ui@^0.
|
|
2589
|
+
"dowel-ui@^0.37.0"
|
|
2502
2590
|
],
|
|
2503
2591
|
"registryDependencies": [],
|
|
2504
2592
|
"files": [
|
|
@@ -2516,7 +2604,7 @@
|
|
|
2516
2604
|
"title": "Wizard",
|
|
2517
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.",
|
|
2518
2606
|
"dependencies": [
|
|
2519
|
-
"dowel-ui@^0.
|
|
2607
|
+
"dowel-ui@^0.37.0"
|
|
2520
2608
|
],
|
|
2521
2609
|
"registryDependencies": [
|
|
2522
2610
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -2531,6 +2619,99 @@
|
|
|
2531
2619
|
}
|
|
2532
2620
|
]
|
|
2533
2621
|
},
|
|
2622
|
+
{
|
|
2623
|
+
"name": "empty-dashboard-screen",
|
|
2624
|
+
"type": "registry:block",
|
|
2625
|
+
"title": "EmptyDashboardScreen",
|
|
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.",
|
|
2627
|
+
"dependencies": [
|
|
2628
|
+
"dowel-ui@^0.37.0"
|
|
2629
|
+
],
|
|
2630
|
+
"registryDependencies": [
|
|
2631
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2632
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2633
|
+
"https://lacodda.github.io/dowel/r/progress.json",
|
|
2634
|
+
"https://lacodda.github.io/dowel/r/stat-tile.json"
|
|
2635
|
+
],
|
|
2636
|
+
"files": [
|
|
2637
|
+
{
|
|
2638
|
+
"path": "blocks/empty-dashboard-screen.tsx",
|
|
2639
|
+
"target": "@components/blocks/empty-dashboard-screen.tsx",
|
|
2640
|
+
"type": "registry:component",
|
|
2641
|
+
"content": "import { useId, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Frame, Pane } from '@/components/ui/frame'\nimport { PageHeader } from '@/components/ui/page-header'\nimport { Progress } from '@/components/ui/progress'\nimport { StatRow, StatTile } from '@/components/ui/stat-tile'\n\n/*\n * EmptyDashboardScreen.\n *\n * A dashboard on its first day: the few things to do before it has anything\n * to show, with the next one ready to press, and the figures it will show\n * once it does - drawn empty, so the screen says what it is for.\n *\n * A dashboard with nothing in it is the first screen a product shows anyone,\n * and the line's products drew it as a dashboard with every number zero - a\n * row of tiles saying 0, 0, 0 and a chart with no line, which reads as broken\n * rather than as new. A zero is a claim: nothing happened. On the first day\n * nothing has had the chance to.\n *\n * So the screen leads with what to do. The steps are a list in order, each\n * marked done or not in words as well as a mark, and only the first one not\n * yet done carries its action: one next thing, not four buttons competing.\n * The figures follow, under their own heading, with a dash where a number\n * will be - the shape of the dashboard to come, not a report of nothing.\n */\n\nexport interface FirstStep {\n id: string\n title: ReactNode\n /** What doing it gets you, in a line. */\n description?: ReactNode\n done: boolean\n /** What does it - drawn on the first step not yet done, so there is one\n * next thing to press. */\n action?: ReactNode\n}\n\n/** A figure the dashboard will show, drawn here without a value. */\nexport interface FutureFigure {\n label: ReactNode\n}\n\n/** Every word the screen shows, in the product's language. */\nexport interface EmptyDashboardScreenWords {\n /** The list's heading: \"Getting started\". */\n steps: string\n /** How far along: \"2 of 4 done\". */\n progress: (done: number, total: number) => string\n /** Said beside a step's mark, for a reader who cannot see it: \"Done\", \"Not yet\". */\n done: string\n notYet: string\n /** The figures' heading: \"What will show here\". */\n figures: string\n}\n\nexport interface EmptyDashboardScreenProps {\n /** \"Welcome to kilna\". */\n title: ReactNode\n description?: ReactNode\n actions?: ReactNode\n steps: readonly FirstStep[]\n /** The figures the full dashboard will have. Leave it out to show only the steps. */\n figures?: readonly FutureFigure[]\n words: EmptyDashboardScreenWords\n className?: string\n}\n\nfunction CheckIcon() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden className=\"size-3.5\">\n <path d=\"M3.5 8.5l3 3 6-7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n )\n}\n\nexport function EmptyDashboardScreen({\n title,\n description,\n actions,\n steps,\n figures,\n words,\n className,\n}: EmptyDashboardScreenProps) {\n const figuresId = useId()\n const done = steps.filter((step) => step.done).length\n const next = steps.findIndex((step) => !step.done)\n\n return (\n <Frame\n head={<PageHeader title={title} description={description} actions={actions} className=\"mb-0 w-full\" />}\n className={className}\n >\n <div className=\"flex min-h-0 w-full max-w-3xl flex-1 flex-col gap-2.5\">\n {/* Sized by its steps and shrinking when the window is shorter: then\n the steps scroll and the figures stay in sight under them. */}\n <Pane\n label={words.steps}\n className=\"flex-initial\"\n head={\n <div className=\"flex w-full flex-wrap items-center gap-x-4 gap-y-2\">\n <h2 className=\"text-base font-semibold text-text\">{words.steps}</h2>\n {/* The count in words beside a bare bar: the bar's own label\n row would add a percentage, and \"25%\" of four steps says\n less than \"1 of 4\". */}\n <div className=\"ml-auto flex items-center gap-3\">\n <span className=\"text-xs text-dim tabular-nums\">{words.progress(done, steps.length)}</span>\n <Progress value={done} max={Math.max(steps.length, 1)} label={words.steps} className=\"w-32\" />\n </div>\n </div>\n }\n bodyClassName=\"p-1.5\"\n >\n <ol className=\"m-0 flex list-none flex-col p-0\">\n {steps.map((step, index) => {\n const current = index === next\n return (\n <li\n key={step.id}\n aria-current={current ? 'step' : undefined}\n className={cn(\n 'flex items-start gap-3 rounded-md px-2.5 py-2.5',\n current && 'bg-accent-soft',\n )}\n >\n <span\n className={cn(\n 'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-2xs font-semibold tabular-nums',\n step.done\n ? 'bg-good-soft text-good'\n : current\n ? 'bg-accent text-on-accent'\n : 'border border-line-2 text-dim',\n )}\n >\n {step.done ? <CheckIcon /> : index + 1}\n </span>\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span className={cn('text-sm font-medium', step.done ? 'text-dim line-through' : 'text-text')}>\n {step.title}\n {/* The mark says it to the eye; this says it to a\n reader who cannot see a tick or a strike. */}\n <span className=\"sr-only\">, {step.done ? words.done : words.notYet}</span>\n </span>\n {step.description === undefined ? null : (\n <span className=\"text-xs text-dim\">{step.description}</span>\n )}\n </div>\n {current && step.action !== undefined ? (\n <div className=\"flex shrink-0 items-center\">{step.action}</div>\n ) : null}\n </li>\n )\n })}\n </ol>\n </Pane>\n\n {figures === undefined || figures.length === 0 ? null : (\n <section\n aria-labelledby={figuresId}\n className=\"flex shrink-0 flex-col gap-3 rounded-lg border border-dashed border-line-2 p-4\"\n >\n <h2 id={figuresId} className=\"caption\">\n {words.figures}\n </h2>\n {/* A dash, not a zero: nothing has had the chance to happen. */}\n <StatRow>\n {figures.map((figure, index) => (\n <StatTile key={index} label={figure.label} value={<span className=\"text-faint\">—</span>} />\n ))}\n </StatRow>\n </section>\n )}\n </div>\n </Frame>\n )\n}\n"
|
|
2642
|
+
}
|
|
2643
|
+
]
|
|
2644
|
+
},
|
|
2645
|
+
{
|
|
2646
|
+
"name": "list-detail-screen",
|
|
2647
|
+
"type": "registry:block",
|
|
2648
|
+
"title": "ListDetailScreen",
|
|
2649
|
+
"description": "A screen of things and the one that is open: a searchable list beside what is open in it, with the empty states, the keyboard and the narrow width a list needs and every product used to draw for itself.",
|
|
2650
|
+
"dependencies": [],
|
|
2651
|
+
"registryDependencies": [
|
|
2652
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
2653
|
+
"https://lacodda.github.io/dowel/r/empty-state.json",
|
|
2654
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2655
|
+
"https://lacodda.github.io/dowel/r/list-row.json",
|
|
2656
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2657
|
+
"https://lacodda.github.io/dowel/r/search-field.json"
|
|
2658
|
+
],
|
|
2659
|
+
"files": [
|
|
2660
|
+
{
|
|
2661
|
+
"path": "blocks/list-detail-screen.tsx",
|
|
2662
|
+
"target": "@components/blocks/list-detail-screen.tsx",
|
|
2663
|
+
"type": "registry:component",
|
|
2664
|
+
"content": "import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { EmptyState } from '@/components/ui/empty-state'\nimport { Frame, ListDetail, ListDetailBack, Pane, useListDetailTurn } from '@/components/ui/frame'\nimport { RowButton, type RowButtonProps } from '@/components/ui/list-row'\nimport { PageHeader } from '@/components/ui/page-header'\nimport { SearchField } from '@/components/ui/search-field'\n\n/*\n * ListDetailScreen.\n *\n * A screen of things and the one that is open: a searchable list beside what\n * is open in it, with the empty states, the keyboard and the narrow width a\n * list needs and every product used to draw for itself.\n *\n * Every product of the line has this screen at least once - works, releases,\n * versions, a queue - and the parts that took each of them longest are not\n * the ones on the stand. They are the three kinds of nothing (none yet, none\n * matching, none open), a keyboard that walks the list the way a mail client\n * does, and a phone, where the list and the open thing take turns and the\n * focus has to follow whichever one is on screen.\n *\n * A block, not a primitive: it lands in the product's `components/blocks` to\n * be read and changed. It is written to be used as it stands all the same -\n * the data and every word come in through props, so a product can keep it\n * unedited for as long as the screen is the usual one, and edit it the day\n * it is not.\n *\n * What it does not own is where the open thing lives. `selected` and\n * `onSelect` are the product's, because in a real product the open thing is\n * an address - `/works/w3` - and the router is the one that knows it.\n */\n\n/** A row of the list, in RowButton's slots. */\nexport interface ListDetailRow {\n /** What identifies the row at a glance: an icon, a status dot, a cover. */\n start?: ReactNode\n title: ReactNode\n /** A second line under the title. */\n description?: ReactNode\n /** What is measured about the row: a date, a count. */\n end?: ReactNode\n}\n\n/** What an empty state says. */\nexport interface EmptyWords {\n title: ReactNode\n body?: ReactNode\n}\n\n/** Every word the screen shows, in the product's language. */\nexport interface ListDetailScreenWords {\n /** What the list is called - the name of its scroll region: \"Works\". */\n list: string\n /** The search field's name and placeholder: \"Find a work\". */\n search: string\n /** What clears the search, in the field and in the empty result: \"Clear the search\". */\n clear: string\n /** The way back to the list where only one of the two fits: \"All works\". */\n back: ReactNode\n /** The list has nothing in it yet. */\n nothingYet: EmptyWords\n /** What was typed matches nothing. */\n nothingFound: EmptyWords\n /** Nothing is open - said where there is room beside the list. */\n nothingOpen: EmptyWords\n}\n\nexport interface ListDetailScreenProps<T> {\n /** The screen's title. */\n title: ReactNode\n /** One sentence under it. */\n description?: ReactNode\n /** What acts on the screen as a whole: New, Import. */\n actions?: ReactNode\n items: readonly T[]\n /** A stable key per item: its id. */\n itemKey: (item: T) => string\n /** How an item reads as a row. */\n row: (item: T) => ListDetailRow\n /** The key of what is open, or null. */\n selected: string | null\n /** Told what to open - or null, to go back to the list. */\n onSelect: (key: string | null) => void\n /** Whether an item answers to what was typed. Leave it out and there is no\n * search: a list of seven does not need one. */\n matches?: (item: T, query: string) => boolean\n /** The open item's title: the heading of its pane and the name of the\n * region that scrolls. Words, because a region is named in words. */\n detailTitle: (item: T) => string\n /** What acts on the open item, beside its title. */\n detailActions?: (item: T) => ReactNode\n /** The open item itself. */\n detail: (item: T) => ReactNode\n /** The one thing worth doing on an empty screen: \"New work\". */\n emptyAction?: ReactNode\n /** A row drawn as something other than a button - the product's link, so\n * a row can be opened in a new tab: `(work) => <Link to={…} />`. A click\n * is then the link's: the router opens it and `selected` follows the\n * address. `onSelect` is still told by the keyboard and the way back. */\n rowRender?: (item: T) => RowButtonProps['render']\n words: ListDetailScreenWords\n className?: string\n}\n\nexport function ListDetailScreen<T>({\n title,\n description,\n actions,\n items,\n itemKey,\n row,\n selected,\n onSelect,\n matches,\n detailTitle,\n detailActions,\n detail,\n emptyAction,\n rowRender,\n words,\n className,\n}: ListDetailScreenProps<T>) {\n const [query, setQuery] = useState('')\n const listRef = useRef<HTMLDivElement>(null)\n const titleRef = useRef<HTMLHeadingElement>(null)\n const searchRef = useRef<HTMLInputElement>(null)\n\n const typed = query.trim()\n const visible = matches && typed !== '' ? items.filter((item) => matches(item, typed)) : items\n const open = selected === null ? undefined : items.find((item) => itemKey(item) === selected)\n\n // Where the focus goes when the list and the open thing trade places on a\n // narrow screen: the frame's rule, shared with every list beside a detail.\n const turn = useListDetailTurn({\n selected,\n title: titleRef,\n rowOf: (key) => listRef.current?.querySelector<HTMLElement>(`[data-row-key=\"${CSS.escape(key)}\"]`) ?? null,\n fallback: searchRef,\n })\n\n const choose = (key: string | null) => {\n turn(key)\n onSelect(key)\n }\n\n /* Up and Down walk the list and open what they land on - the list beside\n * a reading pane, as a mail client has it. Home and End go to the ends.\n * Tab still leaves the list in one step. */\n const walk = (event: KeyboardEvent<HTMLDivElement>) => {\n const rows = [...(listRef.current?.querySelectorAll<HTMLElement>('[data-row-key]') ?? [])]\n if (rows.length === 0) return\n const at = rows.findIndex((element) => element === document.activeElement)\n const next =\n event.key === 'ArrowDown'\n ? Math.min(at + 1, rows.length - 1)\n : event.key === 'ArrowUp'\n ? Math.max(at - 1, 0)\n : event.key === 'Home'\n ? 0\n : event.key === 'End'\n ? rows.length - 1\n : null\n if (next === null || at === -1) return\n event.preventDefault()\n rows[next]!.focus()\n const key = rows[next]!.dataset.rowKey!\n if (key !== selected) choose(key)\n }\n\n const header = <PageHeader title={title} description={description} actions={actions} className=\"mb-0 w-full\" />\n\n // Nothing at all: one empty state for the screen, not an empty list beside\n // an empty pane - two of them say the same thing twice.\n if (items.length === 0) {\n return (\n <Frame head={header} className={className}>\n <EmptyState title={words.nothingYet.title} body={words.nothingYet.body} action={emptyAction} className=\"flex-1\" />\n </Frame>\n )\n }\n\n return (\n <Frame head={header} className={className}>\n <ListDetail\n open={open !== undefined}\n list={\n <Pane\n label={words.list}\n head={\n matches === undefined ? undefined : (\n <SearchField\n ref={searchRef}\n value={query}\n onValueChange={setQuery}\n placeholder={words.search}\n aria-label={words.search}\n clearLabel={words.clear}\n className=\"w-full\"\n />\n )\n }\n bodyClassName=\"p-1\"\n >\n {visible.length === 0 ? (\n <EmptyState\n plain\n variant=\"filtered\"\n title={words.nothingFound.title}\n body={words.nothingFound.body}\n action={\n <Button size=\"sm\" onClick={() => setQuery('')}>\n {words.clear}\n </Button>\n }\n className=\"p-2\"\n />\n ) : (\n <div ref={listRef} onKeyDown={walk} className=\"flex flex-col\">\n {visible.map((item) => {\n const key = itemKey(item)\n const { start, title: rowTitle, description: rowDescription, end } = row(item)\n return (\n <RowButton\n key={key}\n data-row-key={key}\n selected={key === selected}\n start={start}\n description={rowDescription}\n end={end}\n render={rowRender?.(item)}\n onClick={() => {\n // A link opens itself; a button asks the product.\n if (rowRender === undefined) choose(key)\n else turn(key)\n }}\n >\n {rowTitle}\n </RowButton>\n )\n })}\n </div>\n )}\n </Pane>\n }\n detail={\n open === undefined ? (\n // A dashed placeholder where the open thing will stand: that is\n // what a dashed border means everywhere else in the set.\n <EmptyState title={words.nothingOpen.title} body={words.nothingOpen.body} className=\"flex-1\" />\n ) : (\n <Pane\n label={detailTitle(open)}\n head={\n <>\n <ListDetailBack label={words.back} onClick={() => choose(null)} />\n {/* `tabIndex={-1}`: where to put the focus once the list\n has stepped aside, and not a stop on the way through. */}\n <h2\n ref={titleRef}\n tabIndex={-1}\n className=\"min-w-0 flex-1 truncate text-base font-semibold text-text outline-none\"\n >\n {detailTitle(open)}\n </h2>\n {detailActions === undefined ? null : (\n <div className=\"flex shrink-0 items-center gap-2\">{detailActions(open)}</div>\n )}\n </>\n }\n bodyClassName=\"p-4\"\n >\n {detail(open)}\n </Pane>\n )\n }\n />\n </Frame>\n )\n}\n"
|
|
2665
|
+
}
|
|
2666
|
+
]
|
|
2667
|
+
},
|
|
2668
|
+
{
|
|
2669
|
+
"name": "login-screen",
|
|
2670
|
+
"type": "registry:block",
|
|
2671
|
+
"title": "LoginScreen",
|
|
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.",
|
|
2673
|
+
"dependencies": [
|
|
2674
|
+
"dowel-ui@^0.37.0"
|
|
2675
|
+
],
|
|
2676
|
+
"registryDependencies": [
|
|
2677
|
+
"https://lacodda.github.io/dowel/r/alert.json",
|
|
2678
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
2679
|
+
"https://lacodda.github.io/dowel/r/field.json",
|
|
2680
|
+
"https://lacodda.github.io/dowel/r/input.json",
|
|
2681
|
+
"https://lacodda.github.io/dowel/r/panel.json",
|
|
2682
|
+
"https://lacodda.github.io/dowel/r/password-field.json"
|
|
2683
|
+
],
|
|
2684
|
+
"files": [
|
|
2685
|
+
{
|
|
2686
|
+
"path": "blocks/login-screen.tsx",
|
|
2687
|
+
"target": "@components/blocks/login-screen.tsx",
|
|
2688
|
+
"type": "registry:component",
|
|
2689
|
+
"content": "import { useRef, useState, type FormEvent, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Alert } from '@/components/ui/alert'\nimport { Button } from '@/components/ui/button'\nimport { Field } from '@/components/ui/field'\nimport { Input } from '@/components/ui/input'\nimport { Panel } from '@/components/ui/panel'\nimport { PasswordField } from '@/components/ui/password-field'\n\n/*\n * LoginScreen.\n *\n * The page a product opens on for someone who is not signed in yet: a name,\n * a password, a button, and the two ways it can go wrong said differently -\n * the server refused, or the server could not be reached.\n *\n * Every product of the line with a server drew this, and every one drew it\n * slightly wrong in the same few places. The fields zoomed a phone's page on\n * focus (the set's fields are 16px under a finger now, so this one is too).\n * A refusal and a dead network both said \"wrong password\", which sends\n * someone whose password is right to reset it. The password manager filled\n * the wrong box, because the name field was not marked `username`. And a\n * second press while the first was still on its way signed in twice.\n *\n * What goes wrong is the product's to say, so `onSubmit` says it: resolve\n * with nothing and the product moves on; resolve with words and they are\n * shown, the password selected so the next attempt types over it; reject,\n * and the screen says `words.failed` - the server is out of reach, which is\n * a different thing from being refused and should read as one.\n *\n * It is the page, so it is the page's `main`: render it outside the frame of\n * the signed-in product, where there is no rail to sit beside.\n */\n\nexport interface LoginCredentials {\n /** The email or the user name, as typed. */\n identifier: string\n password: string\n}\n\n/** Every word the screen shows, in the product's language. */\nexport interface LoginScreenWords {\n /** The first field's label: \"Email\", or \"User name\". */\n identifier: string\n /** \"Password\". */\n password: string\n /** What the reveal button is called while the password is hidden, and while it shows. */\n show: string\n hide: string\n /** The button: \"Sign in\". */\n submit: ReactNode\n /** The button while the attempt is on its way: \"Signing in…\". */\n submitting: ReactNode\n /** Said when the attempt never got an answer: \"The server cannot be\n * reached. Try again in a moment.\" */\n failed: ReactNode\n}\n\nexport interface LoginScreenProps {\n /** \"Sign in to kilna\". */\n title: ReactNode\n /** A line under it, when the title does not say enough. */\n description?: ReactNode\n /** The product's mark, above the title: `<ProductMark product=\"kilna\" size={40} />`. */\n mark?: ReactNode\n /** What the first field asks for. An email gets the keyboard and the\n * check a browser gives an address; a user name gets neither. */\n identifier?: 'email' | 'username'\n /** Resolve with nothing when signed in; with words when refused - \"That\n * email and password do not match.\" Reject when there was no answer. */\n onSubmit: (credentials: LoginCredentials) => Promise<ReactNode | void>\n /** Under the password: \"Forgot your password?\" */\n extra?: ReactNode\n /** Under the panel: \"No account? Ask whoever runs this server.\" */\n footer?: ReactNode\n words: LoginScreenWords\n className?: string\n}\n\nexport function LoginScreen({\n title,\n description,\n mark,\n identifier = 'email',\n onSubmit,\n extra,\n footer,\n words,\n className,\n}: LoginScreenProps) {\n const [name, setName] = useState('')\n const [password, setPassword] = useState('')\n const [problem, setProblem] = useState<ReactNode>(null)\n const [pending, setPending] = useState(false)\n const passwordRef = useRef<HTMLInputElement>(null)\n\n async function submit(event: FormEvent) {\n event.preventDefault()\n // A second press while the first is on its way would sign in twice.\n if (pending) return\n setProblem(null)\n setPending(true)\n let refusal: ReactNode\n try {\n const answer = await onSubmit({ identifier: name, password })\n // Nothing, null or false is a yes; anything else is the reason for no.\n refusal = answer === undefined || answer === null || answer === false ? null : answer\n } catch {\n refusal = words.failed\n }\n setPending(false)\n if (refusal === null) return\n setProblem(refusal)\n // The next attempt types over the password rather than after it.\n passwordRef.current?.focus()\n passwordRef.current?.select()\n }\n\n return (\n <main className={cn('flex min-h-full flex-1 flex-col items-center justify-center gap-4 p-4', className)}>\n <Panel className=\"flex w-full max-w-sm flex-col gap-6 p-6\">\n <div className=\"flex flex-col items-start gap-3\">\n {mark}\n <div className=\"flex flex-col gap-1\">\n <h1 className=\"text-lg font-semibold text-text\">{title}</h1>\n {description === undefined ? null : <p className=\"text-sm text-dim\">{description}</p>}\n </div>\n </div>\n <form onSubmit={(event) => void submit(event)} className=\"flex flex-col gap-4\">\n <Field label={words.identifier} required>\n <Input\n type={identifier === 'email' ? 'email' : 'text'}\n // `username` either way: it is the token a password manager\n // pairs with `current-password` to know which box is which.\n autoComplete=\"username\"\n autoCapitalize=\"none\"\n spellCheck={false}\n required\n value={name}\n onChange={(event) => setName(event.target.value)}\n />\n </Field>\n <Field label={words.password} required>\n <PasswordField\n ref={passwordRef}\n autoComplete=\"current-password\"\n showLabel={words.show}\n hideLabel={words.hide}\n required\n value={password}\n onValueChange={setPassword}\n />\n </Field>\n {extra === undefined ? null : <div className=\"-mt-2 text-sm\">{extra}</div>}\n {/* `role=\"alert\"`, so the refusal is announced and not only drawn:\n the focus has gone to the password, and a reader on it would\n otherwise hear nothing about why. */}\n {problem === null ? null : (\n <Alert tone=\"bad\" role=\"alert\">\n {problem}\n </Alert>\n )}\n <Button type=\"submit\" variant=\"primary\" disabled={pending} aria-busy={pending || undefined}>\n {pending ? words.submitting : words.submit}\n </Button>\n </form>\n </Panel>\n {footer === undefined ? null : <div className=\"text-sm text-dim\">{footer}</div>}\n </main>\n )\n}\n"
|
|
2690
|
+
}
|
|
2691
|
+
]
|
|
2692
|
+
},
|
|
2693
|
+
{
|
|
2694
|
+
"name": "settings-screen",
|
|
2695
|
+
"type": "registry:block",
|
|
2696
|
+
"title": "SettingsScreen",
|
|
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.",
|
|
2698
|
+
"dependencies": [
|
|
2699
|
+
"dowel-ui@^0.37.0"
|
|
2700
|
+
],
|
|
2701
|
+
"registryDependencies": [
|
|
2702
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2703
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2704
|
+
"https://lacodda.github.io/dowel/r/section-nav.json"
|
|
2705
|
+
],
|
|
2706
|
+
"files": [
|
|
2707
|
+
{
|
|
2708
|
+
"path": "blocks/settings-screen.tsx",
|
|
2709
|
+
"target": "@components/blocks/settings-screen.tsx",
|
|
2710
|
+
"type": "registry:component",
|
|
2711
|
+
"content": "import { useRef, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Frame, ListDetail, ListDetailBack, Pane, useListDetailTurn } from '@/components/ui/frame'\nimport { PageHeader } from '@/components/ui/page-header'\nimport { SectionNav, type SectionNavProps } from '@/components/ui/section-nav'\n\n/*\n * SettingsScreen.\n *\n * Settings in sections: a column of destinations, each named with a line of\n * what is in it, beside the open section - and on a phone, the column first\n * and then the section, with the way back.\n *\n * Harvested from kilna, whose settings sat on one page until a version that\n * counted what was on it: the whole profile editor, a status check, export,\n * backup, import and a command to copy, in one scroll where nobody found the\n * one thing they came for. Each section has its own address now, so it can\n * be linked to and the back button walks between them, and the column says\n * what is in each one - \"Theme, language, start screen\" - so a section is\n * chosen by its contents rather than guessed from a word.\n *\n * The column is the rail's width, not a list's: it holds destinations, one\n * per line, like the app's own rail beside it. It does not scroll with the\n * section, and `sticky` could not give that - sticky travels with the page\n * until it reaches its offset, so a long section dragged the column up as far\n * as the title before pinning, which reads as a menu that scrolls.\n *\n * What is inside a section is the product's: `SettingsGroup` gives it the\n * shape the line's settings share - a caption, a line under it, and the\n * fields one under another at the width of a field.\n */\n\nexport interface SettingsSection {\n id: string\n /** Its name in the column and at the top of the section. */\n label: string\n /** What is in it, under the name in the column. */\n description?: ReactNode\n icon?: ReactNode\n /** The section itself: its groups of fields. */\n content: ReactNode\n /** A bar at the foot of the section - the Save for a section that keeps a\n * draft, in sight however far down the edit was. */\n foot?: ReactNode\n}\n\n/** Every word the screen shows, in the product's language. */\nexport interface SettingsScreenWords {\n /** What the column is called: the landmark's name, kept off the screen\n * where the title already says it - \"Settings\". */\n sections: string\n /** The way back to the column where only one of the two fits: \"All settings\". */\n back: ReactNode\n}\n\nexport interface SettingsScreenProps {\n title: ReactNode\n description?: ReactNode\n sections: readonly SettingsSection[]\n /** The open section's id. Null opens the column on a narrow screen and the\n * first section where there is room for both. */\n active: string | null\n /** Told which section to open - or null, to go back to the column. */\n onSelect: (id: string | null) => void\n /** A section drawn as the product's link, so it has an address:\n * `(section) => <Link to={`/settings/${section.id}`} />`. A press is then\n * the link's; `onSelect` is still told on the way back. */\n sectionRender?: SectionNavProps['render']\n words: SettingsScreenWords\n className?: string\n}\n\nexport function SettingsScreen({\n title,\n description,\n sections,\n active,\n onSelect,\n sectionRender,\n words,\n className,\n}: SettingsScreenProps) {\n const titleRef = useRef<HTMLHeadingElement>(null)\n const columnRef = useRef<HTMLDivElement>(null)\n\n // Where there is room for both, something is always open: the first\n // section stands in for none, and the column says so.\n const shown = sections.find((section) => section.id === active) ?? sections[0]\n\n const turn = useListDetailTurn({\n selected: active,\n title: titleRef,\n // The column's rows, in the order of the sections.\n rowOf: (id) =>\n columnRef.current?.querySelectorAll<HTMLElement>('nav > :not(h2)')[\n sections.findIndex((section) => section.id === id)\n ] ?? null,\n })\n\n const choose = (id: string | null) => {\n turn(id)\n onSelect(id)\n }\n\n const header = <PageHeader title={title} description={description} className=\"mb-0 w-full\" />\n\n if (shown === undefined) return <Frame head={header} className={className}>{null}</Frame>\n\n return (\n <Frame head={header} className={className}>\n <ListDetail\n open={active !== null}\n width=\"rail\"\n list={\n <Pane label={words.sections} bodyClassName=\"p-1.5\">\n <div ref={columnRef}>\n <SectionNav\n label={words.sections}\n labelHidden\n activeId={shown.id}\n items={sections.map(({ id, label, description: hint, icon }) => ({\n id,\n label,\n description: hint,\n icon,\n }))}\n render={sectionRender}\n onSelect={(id) => {\n // A link opens itself; a button asks the product.\n if (sectionRender === undefined) choose(id)\n else turn(id)\n }}\n />\n </div>\n </Pane>\n }\n detail={\n <Pane\n label={shown.label}\n head={\n <>\n <ListDetailBack label={words.back} onClick={() => choose(null)} />\n {/* `tabIndex={-1}`: where the focus goes once the column has\n stepped aside, and not a stop on the way through. */}\n <h2\n ref={titleRef}\n tabIndex={-1}\n className=\"min-w-0 flex-1 truncate text-base font-semibold text-text outline-none\"\n >\n {shown.label}\n </h2>\n </>\n }\n foot={shown.foot}\n bodyClassName=\"flex flex-col gap-6 p-4\"\n >\n {shown.content}\n </Pane>\n }\n />\n </Frame>\n )\n}\n\nexport interface SettingsGroupProps {\n /** The group's caption: \"Appearance\", \"Notifications\". */\n title: ReactNode\n /** A line under it, when the caption does not say enough. */\n description?: ReactNode\n /** Its fields, one under another. */\n children: ReactNode\n className?: string\n}\n\n/**\n * A group of settings inside a section.\n *\n * One field under another at the width of a field, as the mockup lays a\n * settings group out: side by side, a segment and a select read as one\n * control of two halves, and a field the width of the pane asks the eye to\n * cross a monitor between a label and what it names.\n */\nexport function SettingsGroup({ title, description, children, className }: SettingsGroupProps) {\n return (\n <section className={cn('flex max-w-md flex-col gap-4', className)}>\n {/* A heading, not a caption: the fields under it carry captions of\n their own, and a group named in the same small capitals as its\n fields reads as one more field. */}\n <div className=\"flex flex-col gap-0.5\">\n <h3 className=\"text-sm font-semibold text-text\">{title}</h3>\n {description === undefined ? null : <p className=\"text-xs text-dim\">{description}</p>}\n </div>\n {children}\n </section>\n )\n}\n"
|
|
2712
|
+
}
|
|
2713
|
+
]
|
|
2714
|
+
},
|
|
2534
2715
|
{
|
|
2535
2716
|
"extends": "none",
|
|
2536
2717
|
"name": "app",
|
|
@@ -2612,7 +2793,7 @@
|
|
|
2612
2793
|
"path": "files/AGENTS.md",
|
|
2613
2794
|
"target": "~/AGENTS.md",
|
|
2614
2795
|
"type": "registry:file",
|
|
2615
|
-
"content": "# Working on the UI of this project\n\nThis project's interface is built on [dowel](https://lacodda.github.io/dowel),\nthe lacodda line design system. What follows is what an agent — or a person new\nto the codebase — has to know before touching a component.\n\n## Where the components come from\n\nThey were **copied in**, not installed. `npx shadcn add <url>` writes a file\ninto `components/ui/` and from that moment it belongs to this project.\n\n- Editing one is normal. It is this project's file.\n- Re-running `add` for a component **overwrites it, edits included**. There is\n no merge. Before re-adding, check whether the local copy was changed.\n- Adding a new one: `npx shadcn@latest add https://lacodda.github.io/dowel/r/<name>.json`\n\nThe catalogue of what exists is at\n[`/r/registry.json`](https://lacodda.github.io/dowel/r/registry.json), and also\noffline at `node_modules/dowel-ui/registry.json` — the version this project\nactually has. Check there before writing a component from scratch.\n\n## The rules that are not negotiable here\n\n**No colour is ever written down.** Not a hex, not `rgb()`, not a stock Tailwind\ncolour like `zinc-800`, not `bg-white`. Every colour goes through a token:\n`bg-bg`, `bg-raise`, `text-text`, `text-dim`, `border-line`, `bg-accent`,\n`text-on-accent`. The full vocabulary:\n<https://lacodda.github.io/dowel/reference/tokens/>\n\n**No `dark:` utility.** A component does not know which theme it is in — the\ntheme swaps the token underneath. A `dark:` in a component means a token is\nmissing; that is the thing to fix.\n\n*(A translucent black or white is not a colour but a veil: `bg-black/50` over an\nimage is fine.)*\n\n**No native `<select>`.** Use the `Select` or `Combobox` component. The browser\ndraws a native select's popup in the operating system's own chrome, where no\nstylesheet reaches.\n\nBoth rules are enforced by ESLint (`dowel-ui/eslint`), so a violation fails the\nlint, not a review.\n\n## The theme\n\nOne import, plus this product's own colour:\n\n```css\n@import 'tailwindcss';\n@import 'dowel-ui/theme.css';\n@import 'dowel-ui/accents/<product>.css';\n```\n\nEverything else is derived from that one accent — the hover shade, the soft\nfill, the focus ring, and what colour text has to be on an accent fill. **Do not\noverride a derived token** (`--on-accent` especially): it is calculated for\ncontrast, and setting it by hand is how unreadable buttons ship.\n\n## Where to look things up\n\n- Components, one page each: <https://lacodda.github.io/dowel/components/button/>\n- Every component live, in both themes: <https://lacodda.github.io/dowel/stand/>\n- The words this system uses: <https://lacodda.github.io/dowel/concepts/vocabulary/>\n- Mistakes that are actually made against it: <https://lacodda.github.io/dowel/concepts/anti-patterns/>\n- For a machine reader: <https://lacodda.github.io/dowel/llms.txt>, and any page\n as Markdown by appending `.md` to its URL.\n\n## What to do when a component does not fit\n\nIn order:\n\n1. **Check its parts.** dowel exposes them (`DialogPopup`, `DialogTitle`,\n `DialogActions`) rather than hiding them behind props. Most \"it does not fit\"\n is a part that was not reached for.\n2. **Wrap it.** A local wrapper that fixes this product's conventions around a\n dowel component keeps the component upgradable.\n3. **Edit the copy.** It is this project's file. Note that the next `add` will\n overwrite it.\n\nWriting a second component that does the same thing is the option that costs\nlater, and it is the one that happens by default.\n"
|
|
2796
|
+
"content": "# Working on the UI of this project\n\nThis project's interface is built on [dowel](https://lacodda.github.io/dowel),\nthe lacodda line design system. What follows is what an agent — or a person new\nto the codebase — has to know before touching a component.\n\n## Where the components come from\n\nThey were **copied in**, not installed. `npx shadcn add <url>` writes a file\ninto `components/ui/` and from that moment it belongs to this project.\n\n- Editing one is normal. It is this project's file.\n- Re-running `add` for a component **overwrites it, edits included**. There is\n no merge. Before re-adding, check whether the local copy was changed.\n- Adding a new one: `npx shadcn@latest add https://lacodda.github.io/dowel/r/<name>.json`\n\nThe catalogue of what exists is at\n[`/r/registry.json`](https://lacodda.github.io/dowel/r/registry.json), and also\noffline at `node_modules/dowel-ui/registry.json` — the version this project\nactually has. Check there before writing a component from scratch.\n\nWhat changed in a component, release by release, is offline too, at\n`node_modules/dowel-ui/history.json` — the answer to \"why does my copy differ\nfrom the registry I just upgraded to\".\n\n**Blocks are different.** A whole screen — `list-detail-screen`,\n`settings-screen`, `login-screen`, `empty-dashboard-screen` — lands in\n`components/blocks/` and is this project's to change outright: rename it to the\nscreen it is, change what it shows. Start a new screen of that shape from a\nblock rather than from nothing: <https://lacodda.github.io/dowel/blocks/list-detail-screen/>\n\n## The rules that are not negotiable here\n\n**No colour is ever written down.** Not a hex, not `rgb()`, not a stock Tailwind\ncolour like `zinc-800`, not `bg-white`. Every colour goes through a token:\n`bg-bg`, `bg-raise`, `text-text`, `text-dim`, `border-line`, `bg-accent`,\n`text-on-accent`. The full vocabulary:\n<https://lacodda.github.io/dowel/reference/tokens/>\n\n**No `dark:` utility.** A component does not know which theme it is in — the\ntheme swaps the token underneath. A `dark:` in a component means a token is\nmissing; that is the thing to fix.\n\n*(A translucent black or white is not a colour but a veil: `bg-black/50` over an\nimage is fine.)*\n\n**No native `<select>`.** Use the `Select` or `Combobox` component. The browser\ndraws a native select's popup in the operating system's own chrome, where no\nstylesheet reaches.\n\nBoth rules are enforced by ESLint (`dowel-ui/eslint`), so a violation fails the\nlint, not a review.\n\n## The theme\n\nOne import, plus this product's own colour:\n\n```css\n@import 'tailwindcss';\n@import 'dowel-ui/theme.css';\n@import 'dowel-ui/accents/<product>.css';\n```\n\nEverything else is derived from that one accent — the hover shade, the soft\nfill, the focus ring, and what colour text has to be on an accent fill. **Do not\noverride a derived token** (`--on-accent` especially): it is calculated for\ncontrast, and setting it by hand is how unreadable buttons ship.\n\n## Where to look things up\n\n- Components, one page each: <https://lacodda.github.io/dowel/components/button/>\n- Every component live, in both themes: <https://lacodda.github.io/dowel/stand/>\n- The words this system uses: <https://lacodda.github.io/dowel/concepts/vocabulary/>\n- Mistakes that are actually made against it: <https://lacodda.github.io/dowel/concepts/anti-patterns/>\n- For a machine reader: <https://lacodda.github.io/dowel/llms.txt>, and any page\n as Markdown by appending `.md` to its URL.\n\n## What to do when a component does not fit\n\nIn order:\n\n1. **Check its parts.** dowel exposes them (`DialogPopup`, `DialogTitle`,\n `DialogActions`) rather than hiding them behind props. Most \"it does not fit\"\n is a part that was not reached for.\n2. **Wrap it.** A local wrapper that fixes this product's conventions around a\n dowel component keeps the component upgradable.\n3. **Edit the copy.** It is this project's file. Note that the next `add` will\n overwrite it.\n\nWriting a second component that does the same thing is the option that costs\nlater, and it is the one that happens by default.\n"
|
|
2616
2797
|
}
|
|
2617
2798
|
],
|
|
2618
2799
|
"docs": "Replace `<product>` in the accent import with your own, and add whatever else your project expects of an agent."
|