dowel-ui 0.34.1 → 0.36.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 +27 -8
- package/dist/history.json +3749 -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 +277 -119
- package/dist/theme.css +18 -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 `faint` - verified against the grounds at body size; at 10px\n * it is below AA in the light theme, which is a finding about `faint` at every\n * small size and is owned by the contrast work, not by this recipe.\n */\n@utility caption {\n @apply text-2xs font-semibold uppercase tracking-caption text-faint;\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 * Elevation. Three steps, because the products had one and used it for a\n * toast, a dropdown and a modal alike - so a modal never sat further from\n * the page than the menu it covered.\n */\n --shadow-lift: var(--shadow-lift);\n --shadow-raise: var(--shadow-raise);\n --shadow-float: var(--shadow-float);\n\n /*\n * The smallest a pointer target may be.\n *\n * 24 CSS pixels, which is WCAG 2.2's 2.5.8 at AA. Not a matter of taste, and\n * measured rather than assumed: a live run of the stand found four different\n * answers to the same question in one set - a chip's cross at 16, Copyable\n * at 19, CopyButton at 21, a search field's clear at 24. Three of the four\n * fail, and the failure is silent. A cross missed by a thumb on a tablet, or\n * by anyone whose hand is not steady, does not delete the tag and does not\n * say anything either.\n *\n * It is a size token rather than a spacing step because it is a floor, not a\n * rhythm: nothing is ever `--target-min` tall on purpose, things are at\n * least that big. `size-target` and `min-size-target` come free from the\n * namespace, but the way a primitive usually reaches it is `target-min`\n * below, which grows the hit area without growing the glyph.\n */\n --size-target: 24px;\n\n /*\n * The window's own chrome: the strip at the top, the rail at the left, and\n * the buttons that close the window.\n *\n * Four products of the line draw their own title bar - the trade is that a\n * system bar over an application bar costs a strip of every laptop screen\n * for nothing - and by the time the fourth one did it, the strip was 40px\n * in one and 2.4rem in another. Neither number is wrong; having two is. A\n * window's chrome is the part of a product that is supposed to look like\n * the operating system rather than like the product, so it is the last\n * place where two of them should differ.\n *\n * 40px is not a taste either. The window buttons have to clear the pointer\n * target floor with room for the hover fill to read as a button, and the\n * strip has to hold a mark, a trail and a search box at one line of text\n * without the trail wrapping. 40 is where both stop being tight.\n *\n * `--spacing-window-button` is 46px because that is what Windows draws, and\n * a bar whose buttons are narrower than the system's has a gap the pointer\n * falls into on the way to the corner. It is deliberately wider than it is\n * tall: the close button is the one that must be hit without aiming, and on\n * a maximised window it is the screen's corner.\n *\n * These are in the spacing namespace rather than beside `--size-target`,\n * and the reason is mechanical rather than tidy. `--size-*` yields exactly\n * one utility, `size-*`, which sets both axes - so `w-window-button` off a\n * `--size-` token compiles to NOTHING, and a button written that way loses\n * its width with no error anywhere. Every measurement here is one axis: the\n * bar has a height, the rail a width, the button a width unequal to its\n * height. `--spacing-*` is the namespace that feeds `w-`, `h-`, `min-h-`\n * and the padding utilities, so it is the one that can say so. Caught by\n * compiling the utility rather than by reading the stylesheet, which is\n * what `tailwind.test.ts` exists for.\n */\n --spacing-titlebar: 40px;\n --spacing-rail: 216px;\n --spacing-window-button: 46px;\n\n /*\n * The rail with only its icons: `NavRail collapsed`. 56 is a 36px entry -\n * the size of an icon button in the bar above it - with 10px of air on each\n * side, which is the same inset the wide rail gives its entries. Narrower,\n * and the active entry's tint touches the rail's border; wider, and the\n * column reads as a sidebar that forgot its words.\n */\n --spacing-rail-compact: 56px;\n\n /*\n * A list beside what is open in it: `ListDetail`'s column of rows.\n *\n * Wider than the rail on purpose. The rail holds a destination per line -\n * one word and an icon - and a list holds a title with a line under it and\n * a date at the end, which at 216px is cut to three words. kilna drew six\n * such screens with five widths and three gaps before settling on two\n * widths, this and the rail's; 264 is the one its rows read at.\n */\n --spacing-list: 264px;\n\n /*\n * How much of a frameless window's edge can be grabbed to resize it.\n *\n * The system used to own this and gave about 4 pixels plus whatever the\n * border was; a frameless window has no border, so the whole of it has to\n * be put back by hand. Five is the smallest that can be hit reliably with a\n * mouse and the largest that does not steal a click meant for the first\n * line of text - the strips sit above everything, so anything they cover\n * they cover completely.\n *\n * The corner is double, because a corner is aimed at less precisely than an\n * edge and is the one that resizes both axes at once.\n */\n --spacing-resize-edge: 5px;\n --spacing-resize-corner: 10px;\n}\n\n/*\n * `target-min` - visually smaller, clickably larger.\n *\n * The obvious fix for a 16px cross is to make it 24px, and it is the wrong\n * one: a chip is a small thing by design and a cross a third of its height\n * reads as a button with a chip around it. The set's appearance is a\n * decision, and an accessibility floor should not overturn it.\n *\n * So the target grows and the glyph does not. The pseudo-element is centred on\n * the control, takes its size, and refuses to go under the floor - so a\n * control already large enough is untouched, and a smaller one gains an\n * invisible margin of hit area on every side.\n *\n * `position: relative` comes with it rather than being left to the caller: a\n * target that silently does nothing because its parent forgot to establish a\n * containing block is exactly the failure this exists to prevent.\n *\n * Not `padding`, which was the other candidate: padding moves the glyph inside\n * its box and changes how the control sits in a flex row, so every call site\n * would need a compensating negative margin. This changes nothing about\n * layout.\n */\n/*\n * `caption` - the small uppercase label: a field's name, a panel's section, a\n * group of menu items, a rail's group.\n *\n * One role, and the set wrote it seven ways: with and without `font-medium`,\n * with and without a weight at all, so a Field's label and the SectionLabel\n * above it were two weights of the same thing. kilna's screens added\n * thirty-one more by hand. The weight is 600, the mockup's `.lbl` - at 10px\n * a caption lighter than that reads as a smudge rather than a word.\n *\n * A utility rather than a component because it is a role text plays inside\n * things that are already components: a `<legend>`, a `<th>`, a `<label>`.\n * Its colour is `dim`, not `faint`. `faint` is verified against the grounds at\n * body size; at 10px a live axe run measured it at 3.45:1 on a raised surface\n * in the light theme and 3.16:1 in the dark, against AA's 4.5:1 - a caption\n * nobody can read is a caption doing nothing. `dim` passes in both. That\n * `faint` fails at every small size, not only here, is still the contrast\n * work's to gate.\n */\n@utility caption {\n @apply text-2xs font-semibold uppercase tracking-caption text-dim;\n}\n\n@utility target-min {\n position: relative;\n\n &::after {\n content: '';\n position: absolute;\n top: 50%;\n left: 50%;\n translate: -50% -50%;\n width: 100%;\n height: 100%;\n min-width: var(--size-target);\n min-height: var(--size-target);\n }\n}\n\n/*\n * How tall a control is, and how much room a screen gives each row.\n *\n * `h-9` appeared in nine primitives - every field the set has - each spelling\n * it out, which is why `Input` could not be made compact without editing\n * `Input`. One decision made nine times gets one name.\n *\n * **Not in `@theme`, and that is the whole mechanism.** A token declared there\n * is inlined by the compiler: `h-control` came out as `height: 36px`, the\n * override on a container had nothing to bind to, and the density attribute\n * did exactly nothing. Measured in the browser rather than assumed - the\n * container's `--row-control` read 32px while the field it contained stayed\n * 36. Tailwind also writes a fallback for any `var()` it recognises from the\n * theme, so the name has to live outside the theme's namespaces entirely.\n *\n * Declared here as plain custom properties and turned into utilities by hand\n * below, the reference survives, and an override on an ancestor reaches every\n * control under it - which is what makes density a property of a region\n * rather than of a component.\n */\n:root {\n --row-control: 36px;\n --row-control-sm: 32px;\n --row-control-lg: 40px;\n\n /* The same question for a table, whose rows are sized by what is above and\n * below the text rather than by a height. `Table` had its own `density`\n * prop for this, with its own words - `base` and `dense` - which meant the\n * set used one word for two mechanisms and a product had to set both. */\n --row-cell: 10px;\n}\n\n@utility h-control {\n height: var(--row-control);\n}\n\n@utility py-row {\n padding-block: var(--row-cell);\n}\n\n@utility h-control-sm {\n height: var(--row-control-sm);\n}\n\n@utility h-control-lg {\n height: var(--row-control-lg);\n}\n\n/*\n * Density.\n *\n * Two answers to one question, and the set had neither: a product wanting a\n * tighter table wrote `h-8` at every call site, which is the drift the scale\n * exists to stop, one screen at a time.\n *\n * **It is an attribute on a container, not a prop on a component.** A prop\n * would have to be added to every primitive, threaded through every wrapper a\n * product writes, and passed by hand at each call site - and a primitive\n * written next year would not have it. An attribute is inherited:\n *\n * <form data-density=\"compact\"> …every field inside it…\n *\n * That reaches components which did not exist when the product was built, and\n * a product's own components too, as long as they measure in `h-control` like\n * everything else.\n *\n * The floor does not move with it. `--size-target` stays 24px at every\n * density, because WCAG does not care how tight a table is - which is exactly\n * why `target-min` grows the hit area rather than the control: a compact row\n * is still one a hand can hit.\n */\n[data-density='compact'] {\n --row-control: 32px;\n --row-control-sm: 28px;\n --row-control-lg: 36px;\n --row-cell: 6px;\n}\n\n[data-density='comfortable'] {\n --row-control: 40px;\n --row-control-sm: 36px;\n --row-control-lg: 44px;\n --row-cell: 14px;\n}\n\n/*\n * Stacking order.\n *\n * Not in `@theme`: Tailwind has no z-index namespace, so `z-50` is a literal\n * fifty and a named step would not compile. These are custom properties a\n * component reads directly - `z-index: var(--z-modal)`.\n *\n * The order is the products' own, with the gaps closed. They ran 10 for an\n * in-flow popup, 20 sticky, 30 menu, 40 for a floating button, 50 for modals\n * and drawers, then jumped to 70 and 80 for the command palette - which had to\n * clear the modal layer and had no name to do it with.\n */\n:root {\n /*\n * Motion. One duration existed before this - 160ms on a route change - and\n * everything else rode Tailwind's default. These are the steps around it:\n * `quick` for a colour or an opacity that should feel immediate, `base` for\n * something that moves, `slow` for something arriving from off-screen.\n *\n * Not in `@theme`: Tailwind's `duration-*` utility takes a literal number,\n * not a named step, so these are read directly - `transition-duration:\n * var(--duration-base)`. The easing curves opposite them ARE a namespace,\n * so `ease-out` is a class.\n *\n * Every duration here is for people who want motion: `prefers-reduced-\n * motion` cuts them to nothing further down.\n */\n --duration-quick: 120ms;\n --duration-base: 160ms;\n --duration-slow: 240ms;\n\n --z-popup: 10;\n --z-sticky: 20;\n --z-menu: 30;\n --z-floating: 40;\n --z-overlay: 50;\n --z-modal: 60;\n --z-palette: 70;\n --z-toast: 80;\n}\n\n/*\n * Base layer: what every product would otherwise write again. Scoped to\n * elements and to `:focus-visible`, never to a class, so nothing here can\n * collide with a component.\n */\nbody {\n margin: 0;\n background-color: var(--bg);\n color: var(--text);\n font-family: var(--font-sans);\n /* The base step, so text nobody gave a class reads at the scale's own body\n * size. Without it that text inherited the browser's 16px - an empty\n * state's heading and a card's name came out a step and a half above the\n * 12px beside them, in every product of the line.\n *\n * The line height is the base step's own 20 over 14, written as a ratio\n * rather than read from `--text-base--line-height`, and that is deliberate.\n * A length is inherited as a length: with 20px here, every element that\n * sets a size of its own without a line height - a 10px chart label -\n * inherited 20px and stood five pixels taller, found in the pictures of\n * the stand. A ratio is inherited as a ratio. The theme test holds the two\n * numbers to the tokens. */\n font-size: var(--text-base);\n line-height: calc(20 / 14);\n -webkit-font-smoothing: antialiased;\n}\n\n/*\n * How heavy an icon's line is.\n *\n * lucide draws at 2 by default, which is tuned for its own 24px; at the 14 and\n * 16 the line actually uses, a 2px stroke reads heavier than the text beside\n * it. 1.75 is the owner's call for the whole line (24.09), made here once\n * rather than as a `strokeWidth` at every one of a few hundred call sites.\n *\n * Only an icon still on lucide's default is changed. `[stroke-width='2']` is\n * how lucide writes the default, and an icon given another width - by a call\n * site, or by `absoluteStrokeWidth`, which computes one - keeps it. In\n * `@layer base` so a `stroke-*` utility on the icon wins too. A product that\n * draws with some other icon set is untouched: the rule keys off lucide's own\n * class.\n */\n:root {\n --icon-stroke: 1.75;\n}\n\n@layer base {\n svg.lucide[stroke-width='2'] {\n stroke-width: var(--icon-stroke);\n }\n}\n\n/*\n * In `@layer base`, so a component can turn it off.\n *\n * Unlayered, this rule has the same specificity as `focus-visible:outline-none`\n * from Tailwind - both are one pseudo-class - and wins on source order alone,\n * because the theme is imported before the utilities. Every component that\n * draws its own focus ring got this one on top of it: the command palette's\n * field had an accent outline it had explicitly opted out of, and a combobox\n * with chips drew two rings, one around the box and one around the input\n * inside it.\n *\n * A layered rule loses to any unlayered one regardless of specificity, which\n * is the whole point of cascade layers - the base layer states a default and\n * a component overrides it by saying so.\n */\n@layer base {\n :focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition-duration: 0.01ms !important;\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n scroll-behavior: auto !important;\n }\n}\n\n/*\n * Scrollbars. A browser's default bar is a piece of someone else's chrome\n * sitting in the middle of the product - full width, with step arrows. These\n * are the line's own: thin, in the palette, drawn only where something\n * actually scrolls.\n */\n* {\n scrollbar-width: thin;\n scrollbar-color: var(--line-2) transparent;\n}\n\n*::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n*::-webkit-scrollbar-track {\n background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n border: 3px solid transparent;\n border-radius: 999px;\n background: var(--line-2);\n background-clip: content-box;\n /* The thumb is drawn proportional to how much of the document fits on\n * screen, so a long one collapses it to a few pixels: still visible, no\n * longer catchable by a pointer. This is the floor below which it stops\n * being a control. */\n min-height: 2.5rem;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n background: var(--dim);\n background-clip: content-box;\n}\n\n*::-webkit-scrollbar-corner {\n background: transparent;\n}\n\n*::-webkit-scrollbar-button {\n display: none;\n}\n"
|
|
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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.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.36.0"
|
|
803
803
|
],
|
|
804
804
|
"registryDependencies": [
|
|
805
805
|
"https://lacodda.github.io/dowel/r/combobox.json",
|
|
@@ -811,7 +811,25 @@
|
|
|
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\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 `
|
|
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
|
+
}
|
|
816
|
+
]
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
"name": "commands",
|
|
820
|
+
"type": "registry:ui",
|
|
821
|
+
"title": "Commands",
|
|
822
|
+
"description": "One list of everything the application can do, declared where the doing lives - and the keyboard, the command palette and the sheet of shortcuts all read that list, so a key is written once and none of them can be a version behind.",
|
|
823
|
+
"dependencies": [],
|
|
824
|
+
"registryDependencies": [
|
|
825
|
+
"https://lacodda.github.io/dowel/r/shortcut.json"
|
|
826
|
+
],
|
|
827
|
+
"files": [
|
|
828
|
+
{
|
|
829
|
+
"path": "ui/commands.tsx",
|
|
830
|
+
"target": "@ui/commands.tsx",
|
|
831
|
+
"type": "registry:ui",
|
|
832
|
+
"content": "import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from 'react'\nimport {\n formatStroke,\n holdsCommand,\n isTypingTarget,\n parseKeys,\n sameStroke,\n strokeOf,\n typesInField,\n type Stroke,\n} from './shortcut'\n\n/*\n * Commands.\n *\n * One list of everything the application can do, declared where the doing\n * lives - and the keyboard, the command palette and the sheet of shortcuts all\n * read that list, so a key is written once and none of them can be a version\n * behind.\n *\n * Every product that grew a palette wrote a key three times: in the listener\n * that answers it, in the palette row that hints at it, and in the help sheet\n * that lists it. The three drift - the sheet promises a key the listener no\n * longer answers, the palette hints one that was rebound - and nothing says\n * so. Here a command is declared once, `{ id, label, keys, run }`, by the\n * component that owns what it does, for as long as that component is mounted.\n *\n * There is one list per window, not one per provider, because there is one\n * keyboard per window. Two lists would be two listeners on the same keys,\n * which is exactly the conflict this exists to catch - so the list is the\n * module itself, and a command can be declared from anywhere without a\n * provider above it.\n *\n * What the list decides:\n *\n * - **Sequences.** `G D` is G, then D within a second and a half. A key that\n * does not continue the sequence ends it and does nothing else: a `g`\n * pressed by mistake should not turn the next keystroke into a jump.\n * - **Where a key is answered.** Not in a field someone is typing in, unless\n * the command says it belongs there. A plain key - one without Mod, Ctrl\n * or Alt - is not answered inside a dialog, a menu, a listbox or a tree\n * either, which own their letters, nor Enter and Space on whatever has the\n * focus, which they press. A command declared `within` an element answers\n * only while the focus is inside it, and it wins over the rest of the\n * application there.\n * - **Conflicts.** Two commands on the same keys in the same place, or one\n * on a prefix of the other's sequence, cannot both work. The one declared\n * first keeps the keys - mounted earlier, or earlier in the tree when\n * mounted together, so a shell's own commands outrank a screen's - and the\n * other loses them in the keyboard, in the palette's hint and in the sheet\n * alike, and the console says which.\n */\n\nexport interface Command {\n /** Stable from one session to the next: a rebinding is stored against it. */\n id: string\n /** What the palette shows and the sheet lists, in the product's words. */\n label: string\n /** The heading it is listed under, in the palette and in the sheet. */\n group?: string\n /** Drawn at the start of its palette row. */\n icon?: ReactNode\n /** The keys, as `Mod+K` or `G D`. Several are alternatives. */\n keys?: string | readonly string[]\n /**\n * What it does. Leave it out for keys something else already answers -\n * Escape closing a dialog - which then appear in the sheet and take part in\n * the conflicts, but are not run from here.\n */\n run?: () => void\n /** Declared but not answering now. The sheet still lists its keys. */\n enabled?: boolean\n /**\n * Fire even in a field someone is typing in. For a key that cannot be\n * typing - one with Mod, Ctrl or Alt, Escape, a function key - and that\n * belongs wherever the person is: saving, the palette. A key a field uses\n * is refused when declared, because it would be taken from every field in\n * the application.\n */\n whileTyping?: boolean\n /** Answer only while the focus is inside this element. */\n within?: RefObject<Element | null>\n}\n\n/** A command as the palette and the sheet read it. */\nexport interface ListedCommand {\n id: string\n label: string\n group?: string\n icon?: ReactNode\n /** The keys it answers to now, in the line's spelling: after a rebinding,\n * and without any it lost to a conflict. */\n keys: string[]\n /** Present when it can be run from a list - it has something to run, it is\n * enabled, and it is not tied to where the focus is. The palette lists\n * exactly these. */\n run?: () => void\n}\n\n/** Two commands on one set of keys, as the console reports it. */\nexport interface Conflict {\n /** The keys that were lost. */\n keys: string\n /** The command that lost them. */\n lost: string\n /** The keys that kept them: the same, or one a prefix of the other. */\n against: string\n /** The command that kept them. */\n kept: string\n}\n\n/** How long a sequence waits for its next key. Long enough not to hurry\n * anyone; short enough that a forgotten `g` cannot turn a later keystroke\n * into a jump. */\nconst SEQUENCE_WINDOW = 1500\n\n/** Roles that own the plain keys pressed inside them: a dialog is a place of\n * its own, and a menu, a listbox or a tree types ahead. */\nconst OWNS_PLAIN_KEYS =\n '[role=\"dialog\"], [role=\"alertdialog\"], [role=\"menu\"], [role=\"menubar\"], [role=\"listbox\"], [role=\"tree\"], [role=\"treegrid\"]'\n\ninterface Registration {\n commands: readonly Command[]\n /** What the lists show of these commands. When it has not changed, a new\n * render only hands over fresh handlers and nobody re-renders. */\n signature: string\n}\n\ninterface Placed {\n token: number\n index: number\n command: Command\n /** The keys in the line's spelling. */\n keys: string\n steps: Stroke[]\n /** The element it answers inside, or `null` for the whole window. */\n scope: Element | null\n}\n\nconst registrations = new Map<number, Registration>()\nlet nextToken = 0\nlet version = 0\nlet keymap: Record<string, readonly string[]> = {}\nconst listeners = new Set<() => void>()\nconst parsed = new Map<string, Stroke[]>()\nconst refIds = new WeakMap<object, number>()\nlet nextRefId = 0\n\nlet sorted: { version: number; entries: [number, Registration][] } = { version: -1, entries: [] }\n\n/** The registrations in the order of the tree they were declared in. */\nfunction ordered(): [number, Registration][] {\n if (sorted.version !== version) {\n sorted = { version, entries: [...registrations].sort(([left], [right]) => left - right) }\n }\n return sorted.entries\n}\n\nfunction stepsOf(keys: string): Stroke[] {\n let steps = parsed.get(keys)\n if (steps === undefined) {\n steps = parseKeys(keys)\n parsed.set(keys, steps)\n }\n return steps\n}\n\n/** The keys a command is declared with, or the ones it was rebound to. */\nfunction declaredKeys(command: Command): readonly string[] {\n const rebound = keymap[command.id]\n if (rebound !== undefined) return rebound\n if (command.keys === undefined) return []\n return typeof command.keys === 'string' ? [command.keys] : command.keys\n}\n\nfunction refId(ref: object | undefined): number {\n if (ref === undefined) return -1\n let id = refIds.get(ref)\n if (id === undefined) {\n id = nextRefId++\n refIds.set(ref, id)\n }\n return id\n}\n\nfunction signatureOf(commands: readonly Command[]): string {\n return commands\n .map((command) =>\n [\n command.id,\n command.label,\n command.group ?? '',\n declaredKeys(command).join('|'),\n command.enabled === false ? 0 : 1,\n command.whileTyping ? 1 : 0,\n command.run ? 1 : 0,\n refId(command.within),\n ].join('\\u0000'),\n )\n .join('\\u0001')\n}\n\n/** Refuses what cannot work, when it is declared rather than when it is\n * pressed: a key that does not parse never fires, and nobody would know. */\nfunction validate(commands: readonly Command[]): void {\n for (const command of commands) {\n const keys = command.keys === undefined ? [] : typeof command.keys === 'string' ? [command.keys] : command.keys\n for (const written of keys) {\n const steps = stepsOf(written)\n if (command.whileTyping && steps.some(typesInField)) {\n throw new Error(\n `The command \\`${command.id}\\` fires while typing on \\`${written}\\`, which a field uses to type. ` +\n 'Only a key with Mod, Ctrl or Alt, Escape or a function key can fire into a field.',\n )\n }\n }\n }\n}\n\n/** Whether the steps begin with what has been typed. */\nfunction startsWith(steps: Stroke[], typed: Stroke[]): boolean {\n return steps.length >= typed.length && typed.every((stroke, index) => sameStroke(stroke, steps[index]!))\n}\n\n/** Every binding in the window, in the order declared, with the ones lost to\n * a conflict set aside. */\nfunction place(): { kept: Placed[]; disabled: Placed[]; conflicts: Conflict[] } {\n const kept: Placed[] = []\n const disabled: Placed[] = []\n const conflicts: Conflict[] = []\n\n for (const [token, registration] of ordered()) {\n registration.commands.forEach((command, index) => {\n const scope = command.within === undefined ? null : command.within.current\n // Declared inside an element that is not on the page: there is nowhere\n // for it to answer.\n if (command.within !== undefined && scope === null) return\n\n for (const written of declaredKeys(command)) {\n const steps = stepsOf(written)\n const binding: Placed = { token, index, command, keys: steps.map(formatStroke).join(' '), steps, scope }\n if (command.enabled === false) {\n disabled.push(binding)\n continue\n }\n const rival = kept.find(\n (other) =>\n other.scope === scope && (startsWith(other.steps, binding.steps) || startsWith(binding.steps, other.steps)),\n )\n if (rival) {\n conflicts.push({ keys: binding.keys, lost: command.id, against: rival.keys, kept: rival.command.id })\n continue\n }\n kept.push(binding)\n }\n })\n }\n\n return { kept, disabled, conflicts }\n}\n\ninterface Snapshot {\n version: number\n list: ListedCommand[]\n conflicts: Conflict[]\n}\n\nlet snapshot: Snapshot = { version: -1, list: [], conflicts: [] }\n\nfunction read(): Snapshot {\n if (snapshot.version === version) return snapshot\n const { kept, disabled, conflicts } = place()\n const keysOf = (token: number, index: number) =>\n [...kept, ...disabled].filter((binding) => binding.token === token && binding.index === index).map((binding) => binding.keys)\n\n const seen = new Set<string>()\n const list: ListedCommand[] = []\n for (const [token, registration] of ordered()) {\n registration.commands.forEach((command, index) => {\n // The same command declared by two mounted copies of one component is\n // one line in a list.\n if (seen.has(command.id)) return\n seen.add(command.id)\n const runnable = command.run !== undefined && command.enabled !== false && command.within === undefined\n list.push({\n id: command.id,\n label: command.label,\n group: command.group,\n icon: command.icon,\n keys: keysOf(token, index),\n run: runnable ? () => registrations.get(token)?.commands[index]?.run?.() : undefined,\n })\n })\n }\n\n snapshot = { version, list, conflicts }\n return snapshot\n}\n\nlet pending: { strokes: Stroke[]; timer: ReturnType<typeof setTimeout> } | null = null\n\nfunction dropPending(): void {\n if (pending === null) return\n clearTimeout(pending.timer)\n pending = null\n}\n\n/** How deep an element sits, so the innermost place a key is declared in\n * answers it first. The whole window is shallower than anything in it. */\nfunction depthOf(element: Element | null): number {\n let depth = -1\n for (let node = element; node !== null; node = node.parentElement) depth++\n return depth\n}\n\n/** Whether a binding answers this keystroke, landing where it landed. */\nfunction answers(binding: Placed, target: Element | null, stroke: Stroke): boolean {\n const { command, scope } = binding\n if (scope !== null && (target === null || !scope.contains(target))) return false\n if (isTypingTarget(target) && (!command.whileTyping || typesInField(stroke))) return false\n\n if (!holdsCommand(stroke)) {\n const surface = target?.closest(OWNS_PLAIN_KEYS) ?? null\n // Unless the command was declared inside that very place.\n if (surface !== null && (scope === null || !surface.contains(scope))) return false\n const presses = stroke.key === 'Enter' || stroke.key === 'Space'\n const nothingFocused = target === null || target === document.body || target === document.documentElement\n if (presses && scope === null && !nothingFocused) return false\n }\n return true\n}\n\nfunction onKeyDown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return\n // A key held down while a sequence waits is the same key, not the next one.\n if (event.repeat && pending !== null) return\n const stroke = strokeOf(event)\n if (stroke === null) return\n\n const typed = pending === null ? [stroke] : [...pending.strokes, stroke]\n dropPending()\n\n const target = event.target instanceof Element ? event.target : null\n const live = place().kept.filter(\n (binding) => binding.command.run !== undefined && answers(binding, target, stroke),\n )\n const depths = [...new Set(live.map((binding) => depthOf(binding.scope)))].sort((a, b) => b - a)\n\n for (const depth of depths) {\n const here = live.filter((binding) => depthOf(binding.scope) === depth)\n const exact = here.find((binding) => binding.steps.length === typed.length && startsWith(binding.steps, typed))\n if (exact) {\n event.preventDefault()\n registrations.get(exact.token)?.commands[exact.index]?.run?.()\n return\n }\n if (here.some((binding) => binding.steps.length > typed.length && startsWith(binding.steps, typed))) {\n event.preventDefault()\n pending = { strokes: typed, timer: setTimeout(dropPending, SEQUENCE_WINDOW) }\n return\n }\n }\n // Nothing answers. A key that ended a sequence is spent on ending it.\n}\n\nlet listening = false\nlet reportScheduled = false\nconst reported = new Set<string>()\n\n/** Says what lost its keys, once per conflict - after the commit settles, so\n * a strict-mode remount does not report the same thing twice. */\nfunction scheduleReport(): void {\n if (reportScheduled) return\n reportScheduled = true\n queueMicrotask(() => {\n reportScheduled = false\n const current = new Set<string>()\n for (const conflict of read().conflicts) {\n const key = `${conflict.lost}\\u0000${conflict.keys}\\u0000${conflict.kept}\\u0000${conflict.against}`\n current.add(key)\n if (reported.has(key)) continue\n console.error(\n `\\`${conflict.lost}\\` cannot answer to \\`${conflict.keys}\\`: \\`${conflict.kept}\\` already answers to ` +\n `\\`${conflict.against}\\` in the same place, and was declared first.`,\n )\n }\n reported.clear()\n for (const key of current) reported.add(key)\n })\n}\n\nfunction changed(): void {\n version++\n if (registrations.size > 0 && !listening && typeof document !== 'undefined') {\n document.addEventListener('keydown', onKeyDown)\n listening = true\n } else if (registrations.size === 0 && listening) {\n document.removeEventListener('keydown', onKeyDown)\n listening = false\n dropPending()\n }\n for (const listener of listeners) listener()\n scheduleReport()\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Declare commands for as long as the calling component is mounted.\n *\n * The handlers may be written inline: each render hands over the newest ones,\n * and nothing is re-declared - and no list re-renders - unless something a\n * list shows has changed.\n */\nexport function useCommands(commands: readonly Command[]): void {\n const latest = useRef(commands)\n const token = useRef<number | null>(null)\n // The place in the list is taken at the first render, not when the effect\n // runs. Renders go in the order of the tree - a parent before its children,\n // siblings in turn - while effects run children first, which would list a\n // shell's own commands after everything inside it and hand a conflict to\n // whichever component happened to be deepest.\n const [place] = useState(() => nextToken++)\n\n useLayoutEffect(() => {\n latest.current = commands\n if (token.current === null) return\n const registration = registrations.get(token.current)\n if (registration === undefined) return\n const signature = signatureOf(commands)\n if (signature === registration.signature) {\n registration.commands = commands\n return\n }\n validate(commands)\n registrations.set(token.current, { commands, signature })\n changed()\n })\n\n useLayoutEffect(() => {\n const commands = latest.current\n validate(commands)\n registrations.set(place, { commands, signature: signatureOf(commands) })\n token.current = place\n changed()\n return () => {\n registrations.delete(place)\n token.current = null\n changed()\n }\n }, [place])\n}\n\n/** Declare one command for as long as the calling component is mounted. */\nexport function useCommand(command: Command): void {\n useCommands([command])\n}\n\n/** Every declared command, in the order declared, for the palette and the\n * sheet. Re-renders when what they show changes. */\nexport function useCommandList(): ListedCommand[] {\n return useSyncExternalStore(subscribe, () => read().list, () => read().list)\n}\n\n/** The keys one command answers to now - for the hint on a button that does\n * the same, or its `aria-keyshortcuts`. */\nexport function useCommandKeys(id: string): string[] {\n return useCommandList().find((command) => command.id === id)?.keys ?? []\n}\n\n/** The conflicts in the window now, for a settings screen that rebinds keys\n * to warn about before saving - or for a test that says there are none. */\nexport function useCommandConflicts(): Conflict[] {\n return useSyncExternalStore(subscribe, () => read().conflicts, () => read().conflicts)\n}\n\n/** Run a command by its id. False when nothing by that id can be run. */\nexport function runCommand(id: string): boolean {\n for (const [, registration] of ordered()) {\n const command = registration.commands.find((candidate) => candidate.id === id)\n if (command?.run !== undefined && command.enabled !== false) {\n command.run()\n return true\n }\n }\n return false\n}\n\n/**\n * Rebind keys: the person's changes, by command id, as the product stores\n * them. An empty list unbinds a command. Replaces whatever was set before.\n *\n * What is stored is the person's data rather than code, so a shortcut in it\n * that does not parse is reported and skipped rather than thrown: a settings\n * file written by an older version should not take the application down.\n */\nexport function setKeymap(rebound: Record<string, string | readonly string[]>): void {\n const next: Record<string, readonly string[]> = {}\n for (const [id, keys] of Object.entries(rebound)) {\n const list = typeof keys === 'string' ? [keys] : keys\n try {\n list.forEach(stepsOf)\n next[id] = list\n } catch (error) {\n console.error(`The keys rebound for \\`${id}\\` were skipped: ${(error as Error).message}`)\n }\n }\n keymap = next\n changed()\n}\n\n/** A label cut into words, whatever separates them. */\nconst words = (text: string) => text.split(/[^\\p{L}\\p{N}]+/u).filter((word) => word !== '')\n\n/** How well a label answers the terms, best first; `null` when it does not. */\nfunction rank(label: string, terms: string[]): number | null {\n const text = label.toLowerCase()\n if (!terms.every((term) => text.includes(term))) return null\n if (text.startsWith(terms.join(' '))) return 0\n const starts = words(text)\n if (terms.every((term) => starts.some((word) => word.startsWith(term)))) return 1\n return 2\n}\n\n/**\n * The commands whose label answers the query, best first.\n *\n * Every term has to be in the label, in any order - \"theme dark\" finds\n * \"Switch to the dark theme\". A label that begins with the query comes first,\n * one where every term begins a word next, one that merely contains the\n * letters last: \"cal\" is the Calendar long before it is \"Critical\". The sort\n * is stable, so equally good matches keep their order and the list does not\n * shuffle under the cursor as the query grows. An empty query matches all.\n */\nexport function matchCommands<T extends { label: string }>(query: string, commands: readonly T[]): T[] {\n const terms = words(query.toLowerCase())\n if (terms.length === 0) return [...commands]\n return commands\n .map((command) => ({ command, rank: rank(command.label, terms) }))\n .filter((entry): entry is { command: T; rank: number } => entry.rank !== null)\n .sort((left, right) => left.rank - right.rank)\n .map((entry) => entry.command)\n}\n\n/** Commands under their headings, in the order the headings first appear. */\nexport function groupCommands<T extends { group?: string }>(\n commands: readonly T[],\n): { group: string | undefined; commands: T[] }[] {\n const groups: { group: string | undefined; commands: T[] }[] = []\n for (const command of commands) {\n const found = groups.find((entry) => entry.group === command.group)\n if (found) found.commands.push(command)\n else groups.push({ group: command.group, commands: [command] })\n }\n return groups\n}\n"
|
|
815
833
|
}
|
|
816
834
|
]
|
|
817
835
|
},
|
|
@@ -823,7 +841,7 @@
|
|
|
823
841
|
"dependencies": [
|
|
824
842
|
"@base-ui/react",
|
|
825
843
|
"class-variance-authority",
|
|
826
|
-
"dowel-ui@^0.
|
|
844
|
+
"dowel-ui@^0.36.0"
|
|
827
845
|
],
|
|
828
846
|
"registryDependencies": [
|
|
829
847
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -845,7 +863,7 @@
|
|
|
845
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>`.",
|
|
846
864
|
"dependencies": [
|
|
847
865
|
"@base-ui/react",
|
|
848
|
-
"dowel-ui@^0.
|
|
866
|
+
"dowel-ui@^0.36.0"
|
|
849
867
|
],
|
|
850
868
|
"registryDependencies": [
|
|
851
869
|
"https://lacodda.github.io/dowel/r/layer.json",
|
|
@@ -866,7 +884,7 @@
|
|
|
866
884
|
"title": "CopyButton",
|
|
867
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.",
|
|
868
886
|
"dependencies": [
|
|
869
|
-
"dowel-ui@^0.
|
|
887
|
+
"dowel-ui@^0.36.0"
|
|
870
888
|
],
|
|
871
889
|
"registryDependencies": [],
|
|
872
890
|
"files": [
|
|
@@ -884,7 +902,7 @@
|
|
|
884
902
|
"title": "Copyable",
|
|
885
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.",
|
|
886
904
|
"dependencies": [
|
|
887
|
-
"dowel-ui@^0.
|
|
905
|
+
"dowel-ui@^0.36.0"
|
|
888
906
|
],
|
|
889
907
|
"registryDependencies": [],
|
|
890
908
|
"files": [
|
|
@@ -902,7 +920,7 @@
|
|
|
902
920
|
"title": "DatePicker",
|
|
903
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.",
|
|
904
922
|
"dependencies": [
|
|
905
|
-
"dowel-ui@^0.
|
|
923
|
+
"dowel-ui@^0.36.0"
|
|
906
924
|
],
|
|
907
925
|
"registryDependencies": [
|
|
908
926
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -925,7 +943,7 @@
|
|
|
925
943
|
"title": "DateRangePicker",
|
|
926
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.",
|
|
927
945
|
"dependencies": [
|
|
928
|
-
"dowel-ui@^0.
|
|
946
|
+
"dowel-ui@^0.36.0"
|
|
929
947
|
],
|
|
930
948
|
"registryDependencies": [
|
|
931
949
|
"https://lacodda.github.io/dowel/r/calendar.json",
|
|
@@ -950,7 +968,7 @@
|
|
|
950
968
|
"dependencies": [
|
|
951
969
|
"@base-ui/react",
|
|
952
970
|
"class-variance-authority",
|
|
953
|
-
"dowel-ui@^0.
|
|
971
|
+
"dowel-ui@^0.36.0"
|
|
954
972
|
],
|
|
955
973
|
"registryDependencies": [
|
|
956
974
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -960,7 +978,7 @@
|
|
|
960
978
|
"path": "ui/dialog.tsx",
|
|
961
979
|
"target": "@ui/dialog.tsx",
|
|
962
980
|
"type": "registry:ui",
|
|
963
|
-
"content": "import { useRef, type HTMLAttributes, type ReactNode } from 'react'\nimport { Dialog as Base } from '@base-ui/react/dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { LayerProvider } from './layer'\n\n/*\n * Dialog.\n *\n * The parts are exposed rather than wrapped in one component with `title` and\n * `footer` props. The products that did it the other way ended up passing\n * `footer={<>…</>}` within a week, which is a slot with extra steps - and a\n * dialog that owns its own close button owns a word for it, which is a word\n * the product cannot translate.\n *\n * Behaviour is Base UI's: the focus trap, the return of focus to whatever\n * opened it, `Escape`, the scroll lock, and the `aria-labelledby` that ties\n * the popup to its own title. None of that is worth rewriting, and all of it\n * is wrong in the ways nobody notices until someone is navigating by keyboard.\n *\n * What is ours is the clothes, the enter and leave - the popup arrives with\n * `data-open` and leaves with `data-closed`, both of which Base UI sets, so\n * the animation is a class list rather than a state machine - and the anatomy.\n *\n * **Only the body scrolls.** The popup is a column of three parts: a header\n * that stays at the top, a body that takes what height is left and scrolls\n * inside it, and the actions pinned to the bottom. The popup used to scroll as\n * a whole, and a dialog with more in it than the window is tall took its own\n * buttons with it: in kilna, at a 1280x720 window, \"Save\" on the style editor\n * stood 137px below the bottom edge, and the only way to reach it was to\n * scroll a form to its end to find out whether it had one. The popup carries\n * no padding of its own now - each part does - so the body's scroll runs edge\n * to edge and the header and actions stay whole while it moves.\n *\n * **A popup opened inside rides above it.** The content sits in a layer\n * (`layer.tsx`): a select, a menu or a date picker in a dialog portals into a\n * host raised one rung above the modal, so it is neither clipped by the\n * body's scroll nor drawn underneath the dialog that opened it.\n */\n\nexport const dialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'flex flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n // Never taller than the window. The width was capped from the start and\n // the height was not, so a dialog taller than the window centred itself\n // and hung off both ends. The cap is on the column; the body is what\n // gives way.\n 'max-h-[calc(100dvh-2rem)]',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(24rem,calc(100vw-2rem))]',\n md: 'w-[min(28rem,calc(100vw-2rem))]',\n lg: 'w-[min(40rem,calc(100vw-2rem))]',\n // An editor: a form wide enough for its fields to sit side by side,\n // or a row of choices to fit on one line rather than three.\n xl: 'w-[min(48rem,calc(100vw-2rem))]',\n // The window, less a margin that says it is still a dialog. The\n // height is set rather than capped, so the body fills it even when\n // there is little in it - a picker or a preview should not jump in\n // size as its content loads.\n full: 'h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `Dialog.Trigger`. */\nexport const Dialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DialogTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DialogClose = Base.Close\n\n/** The scrim. Dark because a modal is a modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof dialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by whatever it was opened from. Pass an\n * element to put it somewhere else - inside a dialog that is already open,\n * or into a container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself. Portalled, so it is not clipped by whatever it was\n * opened from. Put a `DialogHeader`, a `DialogBody` and `DialogActions`\n * inside it. */\nexport function DialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: DialogPopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n {backdrop && <DialogBackdrop />}\n <Base.Popup\n className={cn(dialogPopupVariants({ size }), '[z-index:var(--z-modal)]', className)}\n {...props}\n >\n <LayerProvider above=\"modal\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/*\n * The three parts. Plain elements rather than Base UI parts, because what\n * they do is layout: which one stays and which one scrolls. That is also why\n * the drawer and the confirm dialog take them from here - the anatomy is one\n * decision, and three copies of it would be three answers by the next\n * version.\n */\n\nexport interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {\n /** Beside the title, at the end of the line: a close button, a menu. The\n * title and description wrap under themselves rather than under it. */\n action?: ReactNode\n}\n\n/** The top of the popup: the title, the line under it, and an action beside\n * them. It stays put while the body scrolls. */\nexport function DialogHeader({ action, className, children, ...props }: DialogHeaderProps) {\n return (\n <div className={cn('flex shrink-0 items-start gap-3 px-5 pt-5 pb-4', className)} {...props}>\n <div className=\"min-w-0 flex-1\">{children}</div>\n {action !== undefined && <div className=\"-mt-1 -mr-1.5 flex shrink-0 items-center gap-1\">{action}</div>}\n </div>\n )\n}\n\n/** The part that scrolls. It takes the height the header and the actions\n * leave, and only it moves when there is more than fits. */\nexport function DialogBody({ className, ...props }:
|
|
981
|
+
"content": "import { useRef, type ComponentProps, type HTMLAttributes, type ReactNode } from 'react'\nimport { Dialog as Base } from '@base-ui/react/dialog'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from 'dowel-ui'\nimport { LayerProvider } from './layer'\n\n/*\n * Dialog.\n *\n * The parts are exposed rather than wrapped in one component with `title` and\n * `footer` props. The products that did it the other way ended up passing\n * `footer={<>…</>}` within a week, which is a slot with extra steps - and a\n * dialog that owns its own close button owns a word for it, which is a word\n * the product cannot translate.\n *\n * Behaviour is Base UI's: the focus trap, the return of focus to whatever\n * opened it, `Escape`, the scroll lock, and the `aria-labelledby` that ties\n * the popup to its own title. None of that is worth rewriting, and all of it\n * is wrong in the ways nobody notices until someone is navigating by keyboard.\n *\n * What is ours is the clothes, the enter and leave - the popup arrives with\n * `data-open` and leaves with `data-closed`, both of which Base UI sets, so\n * the animation is a class list rather than a state machine - and the anatomy.\n *\n * **Only the body scrolls.** The popup is a column of three parts: a header\n * that stays at the top, a body that takes what height is left and scrolls\n * inside it, and the actions pinned to the bottom. The popup used to scroll as\n * a whole, and a dialog with more in it than the window is tall took its own\n * buttons with it: in kilna, at a 1280x720 window, \"Save\" on the style editor\n * stood 137px below the bottom edge, and the only way to reach it was to\n * scroll a form to its end to find out whether it had one. The popup carries\n * no padding of its own now - each part does - so the body's scroll runs edge\n * to edge and the header and actions stay whole while it moves.\n *\n * **A popup opened inside rides above it.** The content sits in a layer\n * (`layer.tsx`): a select, a menu or a date picker in a dialog portals into a\n * host raised one rung above the modal, so it is neither clipped by the\n * body's scroll nor drawn underneath the dialog that opened it.\n */\n\nexport const dialogPopupVariants = cva(\n [\n 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',\n 'flex flex-col overflow-hidden',\n 'rounded-xl border border-line bg-raise text-text shadow-float',\n 'focus-visible:outline-none',\n // Never taller than the window. The width was capped from the start and\n // the height was not, so a dialog taller than the window centred itself\n // and hung off both ends. The cap is on the column; the body is what\n // gives way.\n 'max-h-[calc(100dvh-2rem)]',\n // The enter and the leave. `duration-*` reads the token directly because\n // Tailwind's own utility takes a literal number.\n '[transition:opacity_var(--duration-base)_var(--ease-out),transform_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:scale-[0.98] data-[closed]:opacity-0',\n 'data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0',\n ],\n {\n variants: {\n size: {\n sm: 'w-[min(24rem,calc(100vw-2rem))]',\n md: 'w-[min(28rem,calc(100vw-2rem))]',\n lg: 'w-[min(40rem,calc(100vw-2rem))]',\n // An editor: a form wide enough for its fields to sit side by side,\n // or a row of choices to fit on one line rather than three.\n xl: 'w-[min(48rem,calc(100vw-2rem))]',\n // The window, less a margin that says it is still a dialog. The\n // height is set rather than capped, so the body fills it even when\n // there is little in it - a picker or a preview should not jump in\n // size as its content loads.\n full: 'h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)]',\n },\n },\n defaultVariants: { size: 'md' },\n },\n)\n\n/** The root. Controlled with `open` and `onOpenChange`, or left to manage\n * itself around a `Dialog.Trigger`. */\nexport const Dialog = Base.Root\n\n/** What opens it. Give it `render` to use your own button. */\nexport const DialogTrigger = Base.Trigger\n\n/** What closes it - the same, for a cancel button or an X. */\nexport const DialogClose = Base.Close\n\n/** The scrim. Dark because a modal is a modal: what is behind it is out of\n * reach, and saying so with contrast is the only thing that reads at a\n * glance. */\nexport function DialogBackdrop({ className, ...props }: Base.Backdrop.Props) {\n return (\n <Base.Backdrop\n className={cn(\n 'fixed inset-0 bg-black/50 backdrop-blur-[2px]',\n '[z-index:var(--z-overlay)]',\n '[transition:opacity_var(--duration-base)_var(--ease-out)]',\n 'data-[closed]:opacity-0 data-[starting-style]:opacity-0',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogPopupProps\n extends Base.Popup.Props,\n VariantProps<typeof dialogPopupVariants> {\n /** Where to portal to. Defaults to the document body, which is what keeps\n * the popup from being clipped by whatever it was opened from. Pass an\n * element to put it somewhere else - inside a dialog that is already open,\n * or into a container being screenshotted. */\n container?: Base.Portal.Props['container']\n /** Whether to draw the scrim. On by default, and it should stay on for\n * anything a person actually uses: the dim is what says the page behind is\n * out of reach. Turn it off where the popup is shown alongside other things\n * on purpose - a component gallery, a screenshot - because a scrim is\n * `position: fixed` and covers everything, not only its own container. */\n backdrop?: boolean\n}\n\n/** The dialog itself. Portalled, so it is not clipped by whatever it was\n * opened from. Put a `DialogHeader`, a `DialogBody` and `DialogActions`\n * inside it. */\nexport function DialogPopup({\n size,\n container,\n backdrop = true,\n className,\n children,\n ...props\n}: DialogPopupProps) {\n const portal = useRef<HTMLDivElement>(null)\n\n return (\n <Base.Portal ref={portal} container={container}>\n {backdrop && <DialogBackdrop />}\n <Base.Popup\n className={cn(dialogPopupVariants({ size }), '[z-index:var(--z-modal)]', className)}\n {...props}\n >\n <LayerProvider above=\"modal\" mount={portal}>{children}</LayerProvider>\n </Base.Popup>\n </Base.Portal>\n )\n}\n\n/*\n * The three parts. Plain elements rather than Base UI parts, because what\n * they do is layout: which one stays and which one scrolls. That is also why\n * the drawer and the confirm dialog take them from here - the anatomy is one\n * decision, and three copies of it would be three answers by the next\n * version.\n */\n\nexport interface DialogHeaderProps extends HTMLAttributes<HTMLDivElement> {\n /** Beside the title, at the end of the line: a close button, a menu. The\n * title and description wrap under themselves rather than under it. */\n action?: ReactNode\n}\n\n/** The top of the popup: the title, the line under it, and an action beside\n * them. It stays put while the body scrolls. */\nexport function DialogHeader({ action, className, children, ...props }: DialogHeaderProps) {\n return (\n <div className={cn('flex shrink-0 items-start gap-3 px-5 pt-5 pb-4', className)} {...props}>\n <div className=\"min-w-0 flex-1\">{children}</div>\n {action !== undefined && <div className=\"-mt-1 -mr-1.5 flex shrink-0 items-center gap-1\">{action}</div>}\n </div>\n )\n}\n\n/** The part that scrolls. It takes the height the header and the actions\n * leave, and only it moves when there is more than fits. It takes a ref, for\n * a dialog that opens with the focus on what it says rather than on a button. */\nexport function DialogBody({ className, ...props }: ComponentProps<'div'>) {\n return (\n <div\n className={cn(\n 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-5',\n // First or last in the popup, it takes the padding the missing part\n // would have given - a dialog with no header still has a top edge.\n 'first:pt-5 last:pb-5',\n // A box that scrolls can be focused, and the popup clips what is\n // outside it: the ring is drawn inside.\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent',\n className,\n )}\n {...props}\n />\n )\n}\n\nexport interface DialogActionsProps extends HTMLAttributes<HTMLDivElement> {\n /** At the other end of the row from the answer: the action that is not the\n * answer to the dialog's question - \"Delete\" on an editor whose question is\n * \"Save?\". Kept apart so it is not pressed on the way to \"Save\". */\n start?: ReactNode\n}\n\n/** Where the actions go, pinned to the bottom. Right-aligned, because the\n * primary action of a dialog belongs where the eye leaves the sentence. */\nexport function DialogActions({ start, className, children, ...props }: DialogActionsProps) {\n return (\n <div\n className={cn('mt-auto flex shrink-0 items-center justify-end gap-2 px-5 pt-4 pb-5', className)}\n {...props}\n >\n {start !== undefined && <div className=\"mr-auto flex items-center gap-2\">{start}</div>}\n {children}\n </div>\n )\n}\n\n/** The heading. Base UI points the popup's `aria-labelledby` at it, so a\n * dialog with one is named for a screen reader without anyone arranging it. */\nexport function DialogTitle({ className, ...props }: Base.Title.Props) {\n return <Base.Title className={cn('text-base font-semibold', className)} {...props} />\n}\n\n/** The line under the heading, and the popup's `aria-describedby`. */\nexport function DialogDescription({ className, ...props }: Base.Description.Props) {\n return <Base.Description className={cn('mt-1 text-sm text-dim', className)} {...props} />\n}\n"
|
|
964
982
|
}
|
|
965
983
|
]
|
|
966
984
|
},
|
|
@@ -987,7 +1005,7 @@
|
|
|
987
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.",
|
|
988
1006
|
"dependencies": [
|
|
989
1007
|
"class-variance-authority",
|
|
990
|
-
"dowel-ui@^0.
|
|
1008
|
+
"dowel-ui@^0.36.0"
|
|
991
1009
|
],
|
|
992
1010
|
"registryDependencies": [
|
|
993
1011
|
"https://lacodda.github.io/dowel/r/copy-button.json",
|
|
@@ -1009,7 +1027,7 @@
|
|
|
1009
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.",
|
|
1010
1028
|
"dependencies": [
|
|
1011
1029
|
"class-variance-authority",
|
|
1012
|
-
"dowel-ui@^0.
|
|
1030
|
+
"dowel-ui@^0.36.0"
|
|
1013
1031
|
],
|
|
1014
1032
|
"registryDependencies": [],
|
|
1015
1033
|
"files": [
|
|
@@ -1029,7 +1047,7 @@
|
|
|
1029
1047
|
"dependencies": [
|
|
1030
1048
|
"@base-ui/react",
|
|
1031
1049
|
"class-variance-authority",
|
|
1032
|
-
"dowel-ui@^0.
|
|
1050
|
+
"dowel-ui@^0.36.0"
|
|
1033
1051
|
],
|
|
1034
1052
|
"registryDependencies": [
|
|
1035
1053
|
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
@@ -1050,7 +1068,7 @@
|
|
|
1050
1068
|
"title": "DurationField",
|
|
1051
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.",
|
|
1052
1070
|
"dependencies": [
|
|
1053
|
-
"dowel-ui@^0.
|
|
1071
|
+
"dowel-ui@^0.36.0"
|
|
1054
1072
|
],
|
|
1055
1073
|
"registryDependencies": [
|
|
1056
1074
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1071,7 +1089,7 @@
|
|
|
1071
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.",
|
|
1072
1090
|
"dependencies": [
|
|
1073
1091
|
"class-variance-authority",
|
|
1074
|
-
"dowel-ui@^0.
|
|
1092
|
+
"dowel-ui@^0.36.0"
|
|
1075
1093
|
],
|
|
1076
1094
|
"registryDependencies": [],
|
|
1077
1095
|
"files": [
|
|
@@ -1125,7 +1143,7 @@
|
|
|
1125
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.",
|
|
1126
1144
|
"dependencies": [
|
|
1127
1145
|
"@base-ui/react",
|
|
1128
|
-
"dowel-ui@^0.
|
|
1146
|
+
"dowel-ui@^0.36.0"
|
|
1129
1147
|
],
|
|
1130
1148
|
"registryDependencies": [],
|
|
1131
1149
|
"files": [
|
|
@@ -1143,7 +1161,7 @@
|
|
|
1143
1161
|
"title": "FileDrop",
|
|
1144
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.",
|
|
1145
1163
|
"dependencies": [
|
|
1146
|
-
"dowel-ui@^0.
|
|
1164
|
+
"dowel-ui@^0.36.0"
|
|
1147
1165
|
],
|
|
1148
1166
|
"registryDependencies": [],
|
|
1149
1167
|
"files": [
|
|
@@ -1161,7 +1179,7 @@
|
|
|
1161
1179
|
"title": "FilterPopover",
|
|
1162
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.",
|
|
1163
1181
|
"dependencies": [
|
|
1164
|
-
"dowel-ui@^0.
|
|
1182
|
+
"dowel-ui@^0.36.0"
|
|
1165
1183
|
],
|
|
1166
1184
|
"registryDependencies": [
|
|
1167
1185
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1176,6 +1194,27 @@
|
|
|
1176
1194
|
}
|
|
1177
1195
|
]
|
|
1178
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.36.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
|
+
},
|
|
1179
1218
|
{
|
|
1180
1219
|
"name": "image",
|
|
1181
1220
|
"type": "registry:ui",
|
|
@@ -1183,7 +1222,7 @@
|
|
|
1183
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`.",
|
|
1184
1223
|
"dependencies": [
|
|
1185
1224
|
"class-variance-authority",
|
|
1186
|
-
"dowel-ui@^0.
|
|
1225
|
+
"dowel-ui@^0.36.0"
|
|
1187
1226
|
],
|
|
1188
1227
|
"registryDependencies": [],
|
|
1189
1228
|
"files": [
|
|
@@ -1201,7 +1240,7 @@
|
|
|
1201
1240
|
"title": "InlineField",
|
|
1202
1241
|
"description": "A value that is edited where it is shown: click it, type, Enter or leave to keep, Escape to take it back.",
|
|
1203
1242
|
"dependencies": [
|
|
1204
|
-
"dowel-ui@^0.
|
|
1243
|
+
"dowel-ui@^0.36.0"
|
|
1205
1244
|
],
|
|
1206
1245
|
"registryDependencies": [
|
|
1207
1246
|
"https://lacodda.github.io/dowel/r/field-draft.json"
|
|
@@ -1211,7 +1250,7 @@
|
|
|
1211
1250
|
"path": "ui/inline-field.tsx",
|
|
1212
1251
|
"target": "@ui/inline-field.tsx",
|
|
1213
1252
|
"type": "registry:ui",
|
|
1214
|
-
"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"
|
|
1215
1254
|
}
|
|
1216
1255
|
]
|
|
1217
1256
|
},
|
|
@@ -1221,7 +1260,7 @@
|
|
|
1221
1260
|
"title": "Input",
|
|
1222
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.",
|
|
1223
1262
|
"dependencies": [
|
|
1224
|
-
"dowel-ui@^0.
|
|
1263
|
+
"dowel-ui@^0.36.0"
|
|
1225
1264
|
],
|
|
1226
1265
|
"registryDependencies": [],
|
|
1227
1266
|
"files": [
|
|
@@ -1229,7 +1268,7 @@
|
|
|
1229
1268
|
"path": "ui/input.tsx",
|
|
1230
1269
|
"target": "@ui/input.tsx",
|
|
1231
1270
|
"type": "registry:ui",
|
|
1232
|
-
"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"
|
|
1233
1272
|
}
|
|
1234
1273
|
]
|
|
1235
1274
|
},
|
|
@@ -1255,7 +1294,7 @@
|
|
|
1255
1294
|
"title": "JsonViewer",
|
|
1256
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.",
|
|
1257
1296
|
"dependencies": [
|
|
1258
|
-
"dowel-ui@^0.
|
|
1297
|
+
"dowel-ui@^0.36.0"
|
|
1259
1298
|
],
|
|
1260
1299
|
"registryDependencies": [
|
|
1261
1300
|
"https://lacodda.github.io/dowel/r/json-rows.json"
|
|
@@ -1275,15 +1314,17 @@
|
|
|
1275
1314
|
"title": "Kbd",
|
|
1276
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.",
|
|
1277
1316
|
"dependencies": [
|
|
1278
|
-
"dowel-ui@^0.
|
|
1317
|
+
"dowel-ui@^0.36.0"
|
|
1318
|
+
],
|
|
1319
|
+
"registryDependencies": [
|
|
1320
|
+
"https://lacodda.github.io/dowel/r/shortcut.json"
|
|
1279
1321
|
],
|
|
1280
|
-
"registryDependencies": [],
|
|
1281
1322
|
"files": [
|
|
1282
1323
|
{
|
|
1283
1324
|
"path": "ui/kbd.tsx",
|
|
1284
1325
|
"target": "@ui/kbd.tsx",
|
|
1285
1326
|
"type": "registry:ui",
|
|
1286
|
-
"content": "import type
|
|
1327
|
+
"content": "import { Fragment, type HTMLAttributes } from 'react'\nimport { cn } from 'dowel-ui'\nimport { isApplePlatform, parseKeys, strokeParts } from './shortcut'\n\n/*\n * Kbd.\n *\n * A key, as printed in a menu or a hint: `Ctrl` `K`. It is a `<kbd>` element\n * because that is what the element is for - a screen reader announces it as\n * keyboard input rather than reading a stray capital letter.\n *\n * It reads the line's own notation, the one a command is bound with, so what\n * is shown and what is bound are the same string: `Mod+K` is drawn `Ctrl` `K`\n * here and `⌘` `K` on a Mac, and a product that writes the branch itself is\n * a product whose hint is wrong on one of them.\n *\n * A sequence - `G D` - is drawn as its steps with a mark between them, so\n * G-then-D does not read as G-with-D, and two sequences side by side do not\n * read as one of four steps. The mark is `›`, not a word: a word here would be\n * English the product cannot translate, and the notation has no key by that\n * name, so it cannot be mistaken for one.\n */\n\n/** What a key is called on this platform. `Mod` is the one that differs most:\n * command on Apple platforms, control everywhere else. */\nexport function keyLabel(key: string, apple: boolean = isApplePlatform()): string {\n const shared: Record<string, string> = {\n Enter: '↵',\n Escape: 'Esc',\n ArrowUp: '↑',\n ArrowDown: '↓',\n ArrowLeft: '←',\n ArrowRight: '→',\n Backspace: '⌫',\n Delete: 'Del',\n Tab: '⇥',\n Space: '␣',\n PageUp: 'PgUp',\n PageDown: 'PgDn',\n }\n const perPlatform: Record<string, [apple: string, other: string]> = {\n Mod: ['⌘', 'Ctrl'],\n Ctrl: ['⌃', 'Ctrl'],\n Alt: ['⌥', 'Alt'],\n Shift: ['⇧', 'Shift'],\n }\n\n const platform = perPlatform[key]\n if (platform) return apple ? platform[0] : platform[1]\n return shared[key] ?? key\n}\n\nexport interface KbdProps extends HTMLAttributes<HTMLElement> {\n /** A shortcut in the line's notation - `Mod+K`, `G D`, `?`. Given this, the\n * component draws each key under this platform's own name. Without it, the\n * children are the key. */\n keys?: string\n}\n\nexport function Kbd({ keys, className, children, ...props }: KbdProps) {\n const cap = cn(\n 'inline-flex min-w-5 items-center justify-center rounded-sm border border-line bg-soft',\n 'px-1 py-0.5 font-mono text-2xs leading-none text-dim',\n )\n\n if (keys === undefined) {\n return (\n <kbd className={cn(cap, className)} {...props}>\n {children}\n </kbd>\n )\n }\n\n const apple = isApplePlatform()\n return (\n <span className={cn('inline-flex items-center gap-1', className)} {...props}>\n {parseKeys(keys, apple).map((stroke, step) => (\n <Fragment key={step}>\n {step > 0 && (\n <span aria-hidden className=\"text-2xs leading-none text-dim\">\n ›\n </span>\n )}\n <span data-step={step} className=\"inline-flex items-center gap-0.5\">\n {strokeParts(stroke).map((key) => (\n <kbd key={key} className={cap}>\n {keyLabel(key, apple)}\n </kbd>\n ))}\n </span>\n </Fragment>\n ))}\n </span>\n )\n}\n"
|
|
1287
1328
|
}
|
|
1288
1329
|
]
|
|
1289
1330
|
},
|
|
@@ -1294,7 +1335,7 @@
|
|
|
1294
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.",
|
|
1295
1336
|
"dependencies": [
|
|
1296
1337
|
"class-variance-authority",
|
|
1297
|
-
"dowel-ui@^0.
|
|
1338
|
+
"dowel-ui@^0.36.0"
|
|
1298
1339
|
],
|
|
1299
1340
|
"registryDependencies": [],
|
|
1300
1341
|
"files": [
|
|
@@ -1329,7 +1370,7 @@
|
|
|
1329
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.",
|
|
1330
1371
|
"dependencies": [
|
|
1331
1372
|
"class-variance-authority",
|
|
1332
|
-
"dowel-ui@^0.
|
|
1373
|
+
"dowel-ui@^0.36.0"
|
|
1333
1374
|
],
|
|
1334
1375
|
"registryDependencies": [
|
|
1335
1376
|
"https://lacodda.github.io/dowel/r/line-scale.json"
|
|
@@ -1366,7 +1407,7 @@
|
|
|
1366
1407
|
"description": "A row of a list, one thing said in a line: ListRow for a fact, RowButton for a choice.",
|
|
1367
1408
|
"dependencies": [
|
|
1368
1409
|
"@base-ui/react",
|
|
1369
|
-
"dowel-ui@^0.
|
|
1410
|
+
"dowel-ui@^0.36.0"
|
|
1370
1411
|
],
|
|
1371
1412
|
"registryDependencies": [],
|
|
1372
1413
|
"files": [
|
|
@@ -1374,7 +1415,7 @@
|
|
|
1374
1415
|
"path": "ui/list-row.tsx",
|
|
1375
1416
|
"target": "@ui/list-row.tsx",
|
|
1376
1417
|
"type": "registry:ui",
|
|
1377
|
-
"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"
|
|
1378
1419
|
}
|
|
1379
1420
|
]
|
|
1380
1421
|
},
|
|
@@ -1384,7 +1425,7 @@
|
|
|
1384
1425
|
"title": "MarkedText",
|
|
1385
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.",
|
|
1386
1427
|
"dependencies": [
|
|
1387
|
-
"dowel-ui@^0.
|
|
1428
|
+
"dowel-ui@^0.36.0"
|
|
1388
1429
|
],
|
|
1389
1430
|
"registryDependencies": [],
|
|
1390
1431
|
"files": [
|
|
@@ -1392,7 +1433,7 @@
|
|
|
1392
1433
|
"path": "ui/marked-text.tsx",
|
|
1393
1434
|
"target": "@ui/marked-text.tsx",
|
|
1394
1435
|
"type": "registry:ui",
|
|
1395
|
-
"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"
|
|
1396
1437
|
}
|
|
1397
1438
|
]
|
|
1398
1439
|
},
|
|
@@ -1404,7 +1445,7 @@
|
|
|
1404
1445
|
"dependencies": [
|
|
1405
1446
|
"@base-ui/react",
|
|
1406
1447
|
"class-variance-authority",
|
|
1407
|
-
"dowel-ui@^0.
|
|
1448
|
+
"dowel-ui@^0.36.0"
|
|
1408
1449
|
],
|
|
1409
1450
|
"registryDependencies": [
|
|
1410
1451
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1426,7 +1467,7 @@
|
|
|
1426
1467
|
"dependencies": [
|
|
1427
1468
|
"@base-ui/react",
|
|
1428
1469
|
"class-variance-authority",
|
|
1429
|
-
"dowel-ui@^0.
|
|
1470
|
+
"dowel-ui@^0.36.0"
|
|
1430
1471
|
],
|
|
1431
1472
|
"registryDependencies": [
|
|
1432
1473
|
"https://lacodda.github.io/dowel/r/tooltip.json"
|
|
@@ -1446,7 +1487,7 @@
|
|
|
1446
1487
|
"title": "NotificationBell",
|
|
1447
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.",
|
|
1448
1489
|
"dependencies": [
|
|
1449
|
-
"dowel-ui@^0.
|
|
1490
|
+
"dowel-ui@^0.36.0"
|
|
1450
1491
|
],
|
|
1451
1492
|
"registryDependencies": [
|
|
1452
1493
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -1468,7 +1509,7 @@
|
|
|
1468
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.",
|
|
1469
1510
|
"dependencies": [
|
|
1470
1511
|
"@base-ui/react",
|
|
1471
|
-
"dowel-ui@^0.
|
|
1512
|
+
"dowel-ui@^0.36.0"
|
|
1472
1513
|
],
|
|
1473
1514
|
"registryDependencies": [
|
|
1474
1515
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1478,7 +1519,7 @@
|
|
|
1478
1519
|
"path": "ui/number-field.tsx",
|
|
1479
1520
|
"target": "@ui/number-field.tsx",
|
|
1480
1521
|
"type": "registry:ui",
|
|
1481
|
-
"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 {...props} 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 'w-full min-w-0 bg-transparent px-2.5 text-sm 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"
|
|
1522
|
+
"content": "import type { ReactNode } from 'react'\nimport { NumberField as Base } from '@base-ui/react/number-field'\nimport { cn, useLocale } from 'dowel-ui'\nimport { fieldClasses } from './input'\n\n/*\n * NumberField - a number, and the two ways of changing it.\n *\n * A number typed into a text input is a string that happens to look like a\n * number, and every product then writes the same four fixes: strip the\n * letters, clamp to a range, round to a step, and decide what an empty box\n * means. This is those four, once, plus the stepper - because a value with a\n * small range is faster nudged than typed.\n *\n * Base UI carries the parts that are genuinely hard: the arrow keys with\n * PageUp/PageDown for the large step, the parse of what a person actually\n * types (spaces, a comma for a decimal point, a pasted currency string), and\n * `Intl.NumberFormat` for how it reads back. That last one matters more than\n * it looks: a number field that shows `1234.5` where the reader writes\n * `1 234,5` is a field they have to translate in their head.\n *\n * `unit` is ours, and it is a label rather than part of the value. Putting\n * \"px\" inside the input makes it something to parse and something to delete\n * by accident; beside the input it is a caption that cannot be typed into.\n * The value stays a number.\n *\n * Empty is `null`, not zero. \"No number\" and \"the number zero\" are different\n * facts - a price of nothing and no price yet - and a field that returns 0 for\n * an empty box makes them the same the moment it is saved.\n */\n\nexport interface NumberFieldProps {\n value?: number | null\n defaultValue?: number\n onValueChange?: (value: number | null) => void\n min?: number\n max?: number\n /** What the arrows change it by. */\n step?: number\n /** What PageUp and PageDown change it by, when a single step is too slow. */\n largeStep?: number\n /** How the number reads: `Intl.NumberFormat` options, so a currency or a\n * percentage is a prop rather than a wrapper. */\n format?: Intl.NumberFormatOptions\n /** Which conventions `format` follows. Left alone it is the application's\n * language (see `useLocale`); a product states one only when the figure\n * belongs to a place rather than to a person - a price in a fixed market. */\n locale?: string\n /** What the number is in - `px`, `kg`, `%`. A caption beside the field, not\n * part of the value. */\n unit?: ReactNode\n /** Hide the stepper. For a field with a wide range, where the buttons are\n * an invitation to click sixty times. */\n hideStepper?: boolean\n disabled?: boolean\n readOnly?: boolean\n required?: boolean\n name?: string\n placeholder?: string\n 'aria-label'?: string\n className?: string\n}\n\n/** The stepper's two buttons. Square, the height of the field, and marked\n * `aria-hidden` because the input they belong to already announces its value\n * and its range - a screen reader hearing \"increase, decrease\" as separate\n * controls learns nothing it did not have. */\nconst stepperButton = cn(\n 'flex w-7 shrink-0 items-center justify-center text-dim',\n 'transition-colors hover:bg-soft hover:text-text',\n 'disabled:pointer-events-none disabled:opacity-50',\n)\n\nexport function NumberField({\n unit,\n hideStepper = false,\n className,\n placeholder,\n 'aria-label': ariaLabel,\n locale,\n ...props\n}: NumberFieldProps) {\n const language = useLocale(locale)\n return (\n <Base.Root {...props} 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"
|
|
1482
1523
|
}
|
|
1483
1524
|
]
|
|
1484
1525
|
},
|
|
@@ -1488,7 +1529,7 @@
|
|
|
1488
1529
|
"title": "NumberFormat",
|
|
1489
1530
|
"description": "Two things, and the second is the reason this is a component rather than a call to `toLocaleString` at each site.",
|
|
1490
1531
|
"dependencies": [
|
|
1491
|
-
"dowel-ui@^0.
|
|
1532
|
+
"dowel-ui@^0.36.0"
|
|
1492
1533
|
],
|
|
1493
1534
|
"registryDependencies": [],
|
|
1494
1535
|
"files": [
|
|
@@ -1507,7 +1548,7 @@
|
|
|
1507
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.",
|
|
1508
1549
|
"dependencies": [
|
|
1509
1550
|
"class-variance-authority",
|
|
1510
|
-
"dowel-ui@^0.
|
|
1551
|
+
"dowel-ui@^0.36.0"
|
|
1511
1552
|
],
|
|
1512
1553
|
"registryDependencies": [],
|
|
1513
1554
|
"files": [
|
|
@@ -1525,7 +1566,7 @@
|
|
|
1525
1566
|
"title": "PageSize",
|
|
1526
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.",
|
|
1527
1568
|
"dependencies": [
|
|
1528
|
-
"dowel-ui@^0.
|
|
1569
|
+
"dowel-ui@^0.36.0"
|
|
1529
1570
|
],
|
|
1530
1571
|
"registryDependencies": [
|
|
1531
1572
|
"https://lacodda.github.io/dowel/r/select.json"
|
|
@@ -1545,7 +1586,7 @@
|
|
|
1545
1586
|
"title": "Pagination",
|
|
1546
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.",
|
|
1547
1588
|
"dependencies": [
|
|
1548
|
-
"dowel-ui@^0.
|
|
1589
|
+
"dowel-ui@^0.36.0"
|
|
1549
1590
|
],
|
|
1550
1591
|
"registryDependencies": [
|
|
1551
1592
|
"https://lacodda.github.io/dowel/r/button.json"
|
|
@@ -1566,7 +1607,7 @@
|
|
|
1566
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.",
|
|
1567
1608
|
"dependencies": [
|
|
1568
1609
|
"class-variance-authority",
|
|
1569
|
-
"dowel-ui@^0.
|
|
1610
|
+
"dowel-ui@^0.36.0"
|
|
1570
1611
|
],
|
|
1571
1612
|
"registryDependencies": [],
|
|
1572
1613
|
"files": [
|
|
@@ -1584,7 +1625,7 @@
|
|
|
1584
1625
|
"title": "PasswordField",
|
|
1585
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.",
|
|
1586
1627
|
"dependencies": [
|
|
1587
|
-
"dowel-ui@^0.
|
|
1628
|
+
"dowel-ui@^0.36.0"
|
|
1588
1629
|
],
|
|
1589
1630
|
"registryDependencies": [
|
|
1590
1631
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -1594,7 +1635,7 @@
|
|
|
1594
1635
|
"path": "ui/password-field.tsx",
|
|
1595
1636
|
"target": "@ui/password-field.tsx",
|
|
1596
1637
|
"type": "registry:ui",
|
|
1597
|
-
"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"
|
|
1598
1639
|
}
|
|
1599
1640
|
]
|
|
1600
1641
|
},
|
|
@@ -1606,7 +1647,7 @@
|
|
|
1606
1647
|
"dependencies": [
|
|
1607
1648
|
"@base-ui/react",
|
|
1608
1649
|
"class-variance-authority",
|
|
1609
|
-
"dowel-ui@^0.
|
|
1650
|
+
"dowel-ui@^0.36.0"
|
|
1610
1651
|
],
|
|
1611
1652
|
"registryDependencies": [
|
|
1612
1653
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1628,7 +1669,7 @@
|
|
|
1628
1669
|
"dependencies": [
|
|
1629
1670
|
"@base-ui/react",
|
|
1630
1671
|
"class-variance-authority",
|
|
1631
|
-
"dowel-ui@^0.
|
|
1672
|
+
"dowel-ui@^0.36.0"
|
|
1632
1673
|
],
|
|
1633
1674
|
"registryDependencies": [
|
|
1634
1675
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -1648,7 +1689,7 @@
|
|
|
1648
1689
|
"title": "ProductMark",
|
|
1649
1690
|
"description": "The product-mark primitive.",
|
|
1650
1691
|
"dependencies": [
|
|
1651
|
-
"dowel-ui@^0.
|
|
1692
|
+
"dowel-ui@^0.36.0"
|
|
1652
1693
|
],
|
|
1653
1694
|
"registryDependencies": [],
|
|
1654
1695
|
"files": [
|
|
@@ -1666,7 +1707,7 @@
|
|
|
1666
1707
|
"title": "ProductSwitcher",
|
|
1667
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.",
|
|
1668
1709
|
"dependencies": [
|
|
1669
|
-
"dowel-ui@^0.
|
|
1710
|
+
"dowel-ui@^0.36.0"
|
|
1670
1711
|
],
|
|
1671
1712
|
"registryDependencies": [
|
|
1672
1713
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
@@ -1689,7 +1730,7 @@
|
|
|
1689
1730
|
"dependencies": [
|
|
1690
1731
|
"@base-ui/react",
|
|
1691
1732
|
"class-variance-authority",
|
|
1692
|
-
"dowel-ui@^0.
|
|
1733
|
+
"dowel-ui@^0.36.0"
|
|
1693
1734
|
],
|
|
1694
1735
|
"registryDependencies": [],
|
|
1695
1736
|
"files": [
|
|
@@ -1729,7 +1770,7 @@
|
|
|
1729
1770
|
"dependencies": [
|
|
1730
1771
|
"@base-ui/react",
|
|
1731
1772
|
"class-variance-authority",
|
|
1732
|
-
"dowel-ui@^0.
|
|
1773
|
+
"dowel-ui@^0.36.0"
|
|
1733
1774
|
],
|
|
1734
1775
|
"registryDependencies": [],
|
|
1735
1776
|
"files": [
|
|
@@ -1747,7 +1788,7 @@
|
|
|
1747
1788
|
"title": "RatingScale",
|
|
1748
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\".",
|
|
1749
1790
|
"dependencies": [
|
|
1750
|
-
"dowel-ui@^0.
|
|
1791
|
+
"dowel-ui@^0.36.0"
|
|
1751
1792
|
],
|
|
1752
1793
|
"registryDependencies": [],
|
|
1753
1794
|
"files": [
|
|
@@ -1765,7 +1806,7 @@
|
|
|
1765
1806
|
"title": "RelativeTime",
|
|
1766
1807
|
"description": "The relative-time primitive.",
|
|
1767
1808
|
"dependencies": [
|
|
1768
|
-
"dowel-ui@^0.
|
|
1809
|
+
"dowel-ui@^0.36.0"
|
|
1769
1810
|
],
|
|
1770
1811
|
"registryDependencies": [],
|
|
1771
1812
|
"files": [
|
|
@@ -1783,7 +1824,7 @@
|
|
|
1783
1824
|
"title": "ReorderableList",
|
|
1784
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.",
|
|
1785
1826
|
"dependencies": [
|
|
1786
|
-
"dowel-ui@^0.
|
|
1827
|
+
"dowel-ui@^0.36.0"
|
|
1787
1828
|
],
|
|
1788
1829
|
"registryDependencies": [],
|
|
1789
1830
|
"files": [
|
|
@@ -1801,7 +1842,7 @@
|
|
|
1801
1842
|
"title": "SaveState",
|
|
1802
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.",
|
|
1803
1844
|
"dependencies": [
|
|
1804
|
-
"dowel-ui@^0.
|
|
1845
|
+
"dowel-ui@^0.36.0"
|
|
1805
1846
|
],
|
|
1806
1847
|
"registryDependencies": [
|
|
1807
1848
|
"https://lacodda.github.io/dowel/r/spinner.json"
|
|
@@ -1823,7 +1864,7 @@
|
|
|
1823
1864
|
"dependencies": [
|
|
1824
1865
|
"@base-ui/react",
|
|
1825
1866
|
"class-variance-authority",
|
|
1826
|
-
"dowel-ui@^0.
|
|
1867
|
+
"dowel-ui@^0.36.0"
|
|
1827
1868
|
],
|
|
1828
1869
|
"registryDependencies": [],
|
|
1829
1870
|
"files": [
|
|
@@ -1841,19 +1882,19 @@
|
|
|
1841
1882
|
"title": "SearchField",
|
|
1842
1883
|
"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.",
|
|
1843
1884
|
"dependencies": [
|
|
1844
|
-
"dowel-ui@^0.
|
|
1885
|
+
"dowel-ui@^0.36.0"
|
|
1845
1886
|
],
|
|
1846
1887
|
"registryDependencies": [
|
|
1888
|
+
"https://lacodda.github.io/dowel/r/commands.json",
|
|
1847
1889
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
1848
|
-
"https://lacodda.github.io/dowel/r/kbd.json"
|
|
1849
|
-
"https://lacodda.github.io/dowel/r/shortcut.json"
|
|
1890
|
+
"https://lacodda.github.io/dowel/r/kbd.json"
|
|
1850
1891
|
],
|
|
1851
1892
|
"files": [
|
|
1852
1893
|
{
|
|
1853
1894
|
"path": "ui/search-field.tsx",
|
|
1854
1895
|
"target": "@ui/search-field.tsx",
|
|
1855
1896
|
"type": "registry:ui",
|
|
1856
|
-
"content": "import { useCallback, useRef, type InputHTMLAttributes, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport {
|
|
1897
|
+
"content": "import { useCallback, useRef, type InputHTMLAttributes, type Ref } from 'react'\nimport { cn } from 'dowel-ui'\nimport { useCommandKeys, useCommands, type Command } from './commands'\nimport { fieldClasses } from './input'\nimport { Kbd } from './kbd'\n\n/*\n * SearchField.\n *\n * An Input that knows it is a search box, which is three small things the\n * products kept not doing:\n *\n * - a magnifier, so the field is recognisable before it is read;\n * - a way to clear it that is not \"select all and delete\" - and one that a\n * keyboard can reach, which a decorative `<span>` cannot;\n * - the shortcut that focuses it, shown in the field rather than learned.\n *\n * `type=\"search\"` is deliberate: it is what tells a browser to offer previous\n * queries, and what makes Escape clear the field on the platforms where that\n * is the convention. The browser's own clear button is hidden, because it is\n * drawn in the operating system's chrome and cannot be made to match - the\n * same reason the line does not use a native `<select>`.\n */\n\nexport interface SearchFieldProps\n extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {\n /** The query. Controlled, because a search box that owns its own text\n * cannot be cleared by the thing that owns the results. */\n value: string\n /** Told the new query on every keystroke. */\n onValueChange: (value: string) => void\n /**\n * What the clear button is called, for a screen reader. No default: a word\n * the component invents is a word the product cannot translate.\n *\n * Leave it out and no clear button is drawn - which is the right shape for a\n * field that filters as you type and is cleared by other means.\n */\n clearLabel?: string\n /**\n * The command that focuses the field: `{ id: 'search', label: 'Search',\n * keys: 'Mod+K' }`. Declared like any other command, so it is listed in the\n * sheet of shortcuts and the palette, and a rebinding moves the key and the\n * hint together. The hint at the right of the field shows the keys it\n * answers to now, not the ones it was written with.\n */\n shortcut?: SearchShortcut\n ref?: Ref<HTMLInputElement>\n}\n\n/** A command the field runs itself: focus, and select what is there. */\nexport type SearchShortcut = Pick<Command, 'id' | 'label' | 'group' | 'whileTyping'> & {\n keys: string\n}\n\nexport function SearchField({\n value,\n onValueChange,\n clearLabel,\n shortcut,\n className,\n ref,\n ...props\n}: SearchFieldProps) {\n const own = useRef<HTMLInputElement>(null)\n\n const setRefs = useCallback(\n (element: HTMLInputElement | null) => {\n own.current = element\n if (typeof ref === 'function') ref(element)\n else if (ref) ref.current = element\n },\n [ref],\n )\n\n // Focus and select, so the shortcut replaces a stale query rather than\n // appending to it. Not while someone is typing elsewhere - that is every\n // command's default, and it is the half of this people forget.\n const focusAndSelect = useCallback(() => {\n own.current?.focus()\n own.current?.select()\n }, [])\n useCommands(shortcut === undefined ? [] : [{ ...shortcut, run: focusAndSelect }])\n const hint = useCommandKeys(shortcut?.id ?? '')[0]\n\n const showClear = clearLabel !== undefined && value !== ''\n\n return (\n <div className={cn('relative', className)}>\n <MagnifierIcon />\n\n <input\n ref={setRefs}\n type=\"search\"\n value={value}\n onChange={(event) => onValueChange(event.target.value)}\n className={cn(\n fieldClasses,\n 'h-control pl-8',\n // Room on the right for whatever sits there, and none when nothing\n // does - a field with a permanent gap looks broken.\n showClear && 'pr-8',\n !showClear && hint !== undefined && 'pr-14',\n // The browser's own clear affordance, in the operating system's\n // chrome. Ours is below.\n '[&::-webkit-search-cancel-button]:appearance-none',\n )}\n {...props}\n />\n\n {showClear && (\n <button\n type=\"button\"\n aria-label={clearLabel}\n onClick={() => {\n onValueChange('')\n own.current?.focus()\n }}\n className={cn(\n 'absolute right-1.5 top-1/2 grid size-6 -translate-y-1/2 place-items-center',\n 'rounded-sm text-faint transition-colors hover:bg-soft hover:text-text',\n 'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent',\n )}\n >\n <CrossIcon />\n </button>\n )}\n\n {!showClear && hint !== undefined && (\n // Decorative: the shortcut works whether or not it is read out, and a\n // screen reader announcing \"Control K\" inside a search box is noise.\n <Kbd\n keys={hint}\n aria-hidden\n className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2\"\n />\n )}\n </div>\n )\n}\n\nfunction MagnifierIcon() {\n return (\n <svg\n viewBox=\"0 0 16 16\"\n width=\"14\"\n height=\"14\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.6\"\n aria-hidden\n className=\"pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 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\nfunction CrossIcon() {\n return (\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden>\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
1857
1898
|
}
|
|
1858
1899
|
]
|
|
1859
1900
|
},
|
|
@@ -1864,7 +1905,7 @@
|
|
|
1864
1905
|
"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.",
|
|
1865
1906
|
"dependencies": [
|
|
1866
1907
|
"@base-ui/react",
|
|
1867
|
-
"dowel-ui@^0.
|
|
1908
|
+
"dowel-ui@^0.36.0"
|
|
1868
1909
|
],
|
|
1869
1910
|
"registryDependencies": [],
|
|
1870
1911
|
"files": [
|
|
@@ -1872,7 +1913,7 @@
|
|
|
1872
1913
|
"path": "ui/section-nav.tsx",
|
|
1873
1914
|
"target": "@ui/section-nav.tsx",
|
|
1874
1915
|
"type": "registry:ui",
|
|
1875
|
-
"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-
|
|
1916
|
+
"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"
|
|
1876
1917
|
}
|
|
1877
1918
|
]
|
|
1878
1919
|
},
|
|
@@ -1883,7 +1924,7 @@
|
|
|
1883
1924
|
"description": "Two to five options, one of them chosen, all of them in view - a radio group in a segment's clothes.",
|
|
1884
1925
|
"dependencies": [
|
|
1885
1926
|
"@base-ui/react",
|
|
1886
|
-
"dowel-ui@^0.
|
|
1927
|
+
"dowel-ui@^0.36.0"
|
|
1887
1928
|
],
|
|
1888
1929
|
"registryDependencies": [],
|
|
1889
1930
|
"files": [
|
|
@@ -1903,7 +1944,7 @@
|
|
|
1903
1944
|
"dependencies": [
|
|
1904
1945
|
"@base-ui/react",
|
|
1905
1946
|
"class-variance-authority",
|
|
1906
|
-
"dowel-ui@^0.
|
|
1947
|
+
"dowel-ui@^0.36.0"
|
|
1907
1948
|
],
|
|
1908
1949
|
"registryDependencies": [
|
|
1909
1950
|
"https://lacodda.github.io/dowel/r/input.json",
|
|
@@ -1922,7 +1963,7 @@
|
|
|
1922
1963
|
"name": "shortcut",
|
|
1923
1964
|
"type": "registry:ui",
|
|
1924
1965
|
"title": "Shortcut",
|
|
1925
|
-
"description": "
|
|
1966
|
+
"description": "The one notation the line writes keys in, and the one reading of a keystroke against it, so that what is bound, what is shown and what a person rebinds are the same words.",
|
|
1926
1967
|
"dependencies": [],
|
|
1927
1968
|
"registryDependencies": [],
|
|
1928
1969
|
"files": [
|
|
@@ -1930,7 +1971,30 @@
|
|
|
1930
1971
|
"path": "ui/shortcut.tsx",
|
|
1931
1972
|
"target": "@ui/shortcut.tsx",
|
|
1932
1973
|
"type": "registry:ui",
|
|
1933
|
-
"content": "import { useEffect } from 'react'\n\n/*\n * Shortcut.\n *\n * Two things every product writes and half of them get subtly wrong: deciding\n * whether a keystroke is the shortcut, and deciding whether now is a moment to\n * act on it.\n *\n * The second is the one that bites. A shortcut that fires while someone is\n * typing in a field is a bug nobody can describe afterwards - `Mod+K` inside a\n * text editor means \"delete to end of line\", and a palette that opens on top\n * of it looks like the application misheard. So a keystroke aimed at an input,\n * a textarea or anything `contenteditable` belongs to that thing, always.\n *\n * A shortcut is written the way it is read: `['Mod', 'K']`. `Mod` is command\n * on Apple platforms and control everywhere else, which is the same rule Kbd\n * draws it by, so what is bound and what is shown cannot disagree.\n */\n\n/** Whether the event landed somewhere that owns its own keys. */\nexport function isTypingTarget(target: EventTarget | null): boolean {\n const element = target as HTMLElement | null\n if (!element) return false\n const tag = element.tagName\n // Coerced: `isContentEditable` is computed from layout, and an engine that\n // does not lay out - jsdom, and any server render - leaves it `undefined`\n // rather than `false`. Truthiness is enough for the branch below, but a\n // function that says it returns a boolean has to.\n return tag === 'INPUT' || tag === 'TEXTAREA' || Boolean(element.isContentEditable)\n}\n\n/** Does this event match the shortcut? Exported because a product sometimes\n * has to ask the question inside a handler it already owns. */\nexport function matchesShortcut(event: KeyboardEvent, shortcut: string[]): boolean {\n const wants = new Set(shortcut.map((key) => key.toLowerCase()))\n const mod = wants.delete('mod')\n const shift = wants.delete('shift')\n const alt = wants.delete('alt')\n const [key] = [...wants]\n if (key === undefined) return false\n\n // An exact match on every modifier, in both directions: `Mod+K` must not\n // fire on `Mod+Shift+K`, which is usually a different command entirely.\n if (mod !== (event.metaKey || event.ctrlKey)) return false\n if (shift !== event.shiftKey) return false\n if (alt !== event.altKey) return false\n return event.key.toLowerCase() === key\n}\n\nexport interface UseShortcutOptions {\n /** Bind it at all. For a shortcut that only exists on some screens. */\n enabled?: boolean\n /**\n * Fire even while someone is typing in a field.\n *\n * Off by default, and the default is the point. Turn it on for a shortcut\n * that belongs to the field itself - `Escape` closing the box it is typed\n * in - and never for one that takes the person somewhere else.\n */\n whileTyping?: boolean\n}\n\n/**\n * Run something when a shortcut is pressed.\n *\n * The handler is read from a ref, so a caller that writes it inline does not\n * rebind the listener on every render - which is how these end up firing twice.\n */\nexport function useShortcut(\n shortcut: string[],\n onPress: (event: KeyboardEvent) => void,\n { enabled = true, whileTyping = false }: UseShortcutOptions = {},\n): void {\n useEffect(() => {\n if (!enabled) return\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!whileTyping && isTypingTarget(event.target)) return\n if (!matchesShortcut(event, shortcut)) return\n event.preventDefault()\n onPress(event)\n }\n\n document.addEventListener('keydown', onKeyDown)\n return () => document.removeEventListener('keydown', onKeyDown)\n // `shortcut` is an array literal at most call sites, so it is joined\n // rather than compared by identity: otherwise every render rebinds.\n }, [shortcut.join('+'), onPress, enabled, whileTyping]) // eslint-disable-line react-hooks/exhaustive-deps\n}\n"
|
|
1974
|
+
"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 * Shortcut.\n *\n * The one notation the line writes keys in, and the one reading of a\n * keystroke against it, so that what is bound, what is shown and what a\n * person rebinds are the same words.\n *\n * A shortcut is a string, written the way it is read: `Mod+K`, `Mod+Shift+P`,\n * `?`, `Escape`. A sequence is its steps with a space between them - `G D` is\n * G, then D. A string because it has to live in more places than code: in a\n * settings file where someone has rebound it, in a menu hint, in the sheet that\n * lists every key, and in a comparison that says two commands share one.\n *\n * `Mod` is command on Apple platforms and control everywhere else. `Ctrl` is\n * the control key itself, which only means something different on a Mac -\n * `Ctrl+Tab` switches tabs there, where `Mod+Tab` belongs to the system.\n *\n * Two rules decide what a keystroke *is*, and both are about the people the\n * line is made for, who switch keyboard layouts in the middle of a sentence:\n *\n * - A letter is the letter the key types when the layout types Latin, and\n * the key's place when it does not. On a Russian layout `Ctrl+P` arrives\n * as `з`; reading `event.key` there makes every shortcut in the\n * application stop working the moment someone switches language to write.\n * - Any other character is the character typed, with Shift inside it. `?`\n * is Shift+/ on one keyboard and Shift+7 on another, and the person\n * pressing it means the question mark on both. So `?` is written as `?`,\n * never as `Shift+/` - and the notation refuses the second spelling,\n * because two spellings of one key is how two commands end up on it\n * without a conflict ever being seen.\n *\n * The number row under a command modifier is read by place as well: `Mod+1`\n * on a French keyboard is pressed on the key that types `&`.\n */\n\n/** One step of a shortcut: a key and the modifiers held with it. */\nexport interface Stroke {\n /** An uppercase letter, a digit, a character such as `?`, or a key's name\n * such as `Escape` or `F5`. */\n key: string\n /** Command on Apple platforms, Control everywhere else. */\n mod: boolean\n /** The Control key on an Apple platform. Elsewhere Control *is* `Mod`. */\n ctrl: boolean\n alt: boolean\n /** Never set for a character: the character already carries it. */\n shift: boolean\n}\n\n/** Whether this machine writes and reads shortcuts the Apple way. */\nexport function isApplePlatform(): boolean {\n if (typeof navigator === 'undefined') return false\n return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)\n}\n\n/** Keys that have a name rather than a character, by the name `event.key`\n * gives them. Matched without regard to case, so `escape` is `Escape`. */\nconst NAMED = [\n 'Escape',\n 'Enter',\n 'Tab',\n 'Space',\n 'Backspace',\n 'Delete',\n 'Insert',\n 'Home',\n 'End',\n 'PageUp',\n 'PageDown',\n 'ArrowUp',\n 'ArrowDown',\n 'ArrowLeft',\n 'ArrowRight',\n ...Array.from({ length: 24 }, (_, index) => `F${index + 1}`),\n]\nconst NAMED_BY_LOWER = new Map(NAMED.map((name) => [name.toLowerCase(), name]))\n\n/** The other spellings a person reaches for. Each turns into the one above,\n * so two spellings can never be two keys. */\nconst ALIASES: Record<string, string> = {\n esc: 'Escape',\n return: 'Enter',\n del: 'Delete',\n up: 'ArrowUp',\n down: 'ArrowDown',\n left: 'ArrowLeft',\n right: 'ArrowRight',\n plus: '+',\n}\n\nconst MODIFIERS: Record<string, keyof Omit<Stroke, 'key'>> = {\n mod: 'mod',\n ctrl: 'ctrl',\n control: 'ctrl',\n alt: 'alt',\n option: 'alt',\n shift: 'shift',\n}\n\n/** What the punctuation keys type on a US layout, unshifted and shifted - the\n * place a key is read by when the layout typed a letter of its own there. */\nconst US_PUNCTUATION: Record<string, [string, string]> = {\n BracketLeft: ['[', '{'],\n BracketRight: [']', '}'],\n Semicolon: [';', ':'],\n Quote: [\"'\", '\"'],\n Comma: [',', '<'],\n Period: ['.', '>'],\n Slash: ['/', '?'],\n Backslash: ['\\\\', '|'],\n Backquote: ['`', '~'],\n Minus: ['-', '_'],\n Equal: ['=', '+'],\n}\n\n/** Keys that are only ever held. Pressed alone they are a hand reaching for a\n * shortcut, not a shortcut. */\nconst HELD = new Set(['Control', 'Shift', 'Alt', 'Meta', 'AltGraph', 'OS', 'Hyper', 'Super', 'Fn', 'CapsLock'])\n\nconst isLetter = (key: string) => /^[A-Z]$/.test(key)\nconst isDigit = (key: string) => /^[0-9]$/.test(key)\n\n/** A printable character other than a letter: it carries its own Shift. */\nfunction isCharacter(key: string): boolean {\n return key.length === 1 && !isLetter(key)\n}\n\n/** Whether Mod, Ctrl or Alt is held - the modifiers that make a key a command\n * rather than something typed. Shift alone does not. */\nexport function holdsCommand(stroke: Stroke): boolean {\n return stroke.mod || stroke.ctrl || stroke.alt\n}\n\n/** Whether a digit is read by place in this stroke: under a command modifier.\n * Without one it is the character typed, Shift and all. */\nconst digitByPlace = (stroke: Stroke) => isDigit(stroke.key) && holdsCommand(stroke)\n\nfunction parseStep(step: string, whole: string, apple: boolean): Stroke {\n // `+` is both the separator and a key, so `Mod++` and a lone `+` are the\n // plus key, and so is `Mod+Plus`.\n const tokens =\n step === '+' ? ['+'] : step.endsWith('++') ? [...step.slice(0, -2).split('+'), '+'] : step.split('+')\n const written = tokens.pop()!\n const stroke: Stroke = { key: '', mod: false, ctrl: false, alt: false, shift: false }\n\n for (const token of tokens) {\n const modifier = MODIFIERS[token.toLowerCase()]\n if (modifier === undefined) {\n throw new Error(\n `\\`${token}\\` in the shortcut \\`${whole}\\` is not a modifier. The line writes Mod, Ctrl, Alt and Shift - ` +\n 'Mod is command on Apple platforms and control everywhere else, so there is no Cmd to write.',\n )\n }\n if (stroke[modifier]) throw new Error(`The shortcut \\`${whole}\\` names \\`${token}\\` twice.`)\n stroke[modifier] = true\n }\n\n const lower = written.toLowerCase()\n const named = NAMED_BY_LOWER.get(lower) ?? ALIASES[lower]\n if (named !== undefined) stroke.key = named\n else if (written.length === 1 && written >= '!' && written <= '~') stroke.key = written.toUpperCase()\n else {\n throw new Error(\n `\\`${written}\\` in the shortcut \\`${whole}\\` is not a key the line can read. Write a Latin letter, ` +\n 'a digit, a character such as `?`, or a name such as `Escape` - a letter of another alphabet is ' +\n 'found by its place on the keyboard, so it is written as the Latin letter it shares a key with.',\n )\n }\n\n if (stroke.shift && isCharacter(stroke.key) && !digitByPlace(stroke)) {\n throw new Error(\n `The shortcut \\`${whole}\\` holds Shift with \\`${stroke.key}\\`, and a character carries its own Shift: ` +\n 'write the character it types instead - `?`, not `Shift+/`.',\n )\n }\n // Off an Apple platform the control key is the command key: `Ctrl+K` and\n // `Mod+K` are one keystroke there, and spelled one way they collide where\n // they should.\n if (!apple && stroke.ctrl) {\n stroke.ctrl = false\n stroke.mod = true\n }\n return stroke\n}\n\n/**\n * The steps of a shortcut. Throws on anything it cannot read, and says why:\n * a shortcut is written in code, and a typo in one is a key that silently\n * never fires.\n */\nexport function parseKeys(keys: string, apple: boolean = isApplePlatform()): Stroke[] {\n const steps = keys.trim().split(/\\s+/)\n if (steps[0] === '') throw new Error('An empty shortcut binds nothing.')\n return steps.map((step) => parseStep(step, keys, apple))\n}\n\n/** The keys of one step in the one order: Mod, Ctrl, Alt, Shift, the key. */\nexport function strokeParts(stroke: Stroke): string[] {\n const parts: string[] = []\n if (stroke.mod) parts.push('Mod')\n if (stroke.ctrl) parts.push('Ctrl')\n if (stroke.alt) parts.push('Alt')\n if (stroke.shift) parts.push('Shift')\n parts.push(stroke.key)\n return parts\n}\n\n/** One step, written the one way. */\nexport function formatStroke(stroke: Stroke): string {\n return strokeParts(stroke).join('+')\n}\n\n/** A shortcut in its one spelling: `mod+shift+p` is `Mod+Shift+P`, `Esc` is\n * `Escape`. Two shortcuts are the same keys exactly when these agree. */\nexport function normalizeKeys(keys: string, apple: boolean = isApplePlatform()): string {\n return parseKeys(keys, apple).map(formatStroke).join(' ')\n}\n\n/** Whether two steps are the same keystroke. Exact on every modifier, in both\n * directions: `Mod+K` is not `Mod+Shift+K`, and a bare `K` is not `Ctrl+K`. */\nexport function sameStroke(a: Stroke, b: Stroke): boolean {\n return a.key === b.key && a.mod === b.mod && a.ctrl === b.ctrl && a.alt === b.alt && a.shift === b.shift\n}\n\n/** The key a keystroke pressed, by the two rules at the top of this file, and\n * whether Shift went into it. */\nfunction readKey(event: KeyboardEvent, command: boolean): { key: string; character: boolean } | null {\n const { key } = event\n const code = event.code ?? ''\n\n if (/^[a-z]$/i.test(key)) return { key: key.toUpperCase(), character: false }\n // The number row under a command, by place: a French keyboard types `&`\n // on the key that `Mod+1` is pressed on.\n if (command && /^Digit[0-9]$/.test(code)) return { key: code.slice(5), character: false }\n if (key === ' ') return { key: 'Space', character: false }\n if (key.length === 1 && key >= '!' && key <= '~') return { key, character: true }\n\n const named = NAMED_BY_LOWER.get(key.toLowerCase())\n if (named !== undefined) return { key: named, character: false }\n\n // A layout that does not type Latin, or a dead key: the key's place.\n if (/^Key[A-Z]$/.test(code)) return { key: code.slice(3), character: false }\n if (command && /^Digit[0-9]$/.test(code)) return { key: code.slice(5), character: false }\n const punctuation = US_PUNCTUATION[code]\n if (punctuation) return { key: punctuation[event.shiftKey ? 1 : 0], character: true }\n return null\n}\n\n/**\n * What a keystroke is, as a step of a shortcut - or `null` when it is not one:\n * a modifier pressed alone, a key mid-composition in an input method, or\n * AltGr typing a character (Polish `ą` arrives as Ctrl+Alt+A on Windows, and\n * it is a letter, not a command).\n */\nexport function strokeOf(event: KeyboardEvent, apple: boolean = isApplePlatform()): Stroke | null {\n if (event.isComposing || event.keyCode === 229) return null\n if (HELD.has(event.key)) return null\n if (event.getModifierState?.('AltGraph')) return null\n\n let mod: boolean\n let ctrl: boolean\n if (apple) {\n mod = event.metaKey\n ctrl = event.ctrlKey\n } else {\n // The Windows key belongs to the system: nearly every chord on it is\n // taken before a page sees it, and the rest are not the page's to take.\n if (event.metaKey) return null\n mod = event.ctrlKey\n ctrl = false\n }\n const alt = event.altKey\n\n const read = readKey(event, mod || ctrl || alt)\n if (read === null) return null\n return { key: read.key, mod, ctrl, alt, shift: read.character ? false : event.shiftKey }\n}\n\n/** A keystroke written as a shortcut, for a settings screen that records one:\n * press the keys, store what this returns. */\nexport function keysOf(event: KeyboardEvent, apple: boolean = isApplePlatform()): string | null {\n const stroke = strokeOf(event, apple)\n return stroke === null ? null : formatStroke(stroke)\n}\n\n/**\n * Whether a field someone is typing in uses this key - a character, Space,\n * Enter, Tab, the arrows, Home and End, deleting.\n *\n * Only Escape and the function keys type nothing and move nothing, so they and\n * any step holding Mod, Ctrl or Alt are the only ones that can be allowed to\n * fire into a field.\n */\nexport function typesInField(stroke: Stroke): boolean {\n if (holdsCommand(stroke)) return false\n return stroke.key !== 'Escape' && !/^F[0-9]+$/.test(stroke.key)\n}\n\n/** Input types that hold no text: a keystroke on a checkbox is not typing. */\nconst NOT_TEXT = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file', 'image', 'hidden'])\n\n/**\n * Whether the event landed somewhere that owns its own keys: a text input, a\n * textarea, a select, or anything editable - including an element *inside*\n * an editable one, which is what `target` reports for a bold word in an\n * editable paragraph.\n */\nexport function isTypingTarget(target: EventTarget | null): boolean {\n const element = target as Element | null\n if (!element || typeof element.closest !== 'function') return false\n // Asked first, because a browser also sets it for a document in design\n // mode, which no selector sees. An engine that does not lay out - jsdom, a\n // server render - leaves it `undefined`, and the selector below answers.\n if ((element as HTMLElement).isContentEditable) return true\n const field = element.closest('input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])')\n if (field === null) return false\n if (field.tagName === 'INPUT') return !NOT_TEXT.has((field as HTMLInputElement).type)\n return true\n}\n\n/**\n * The shortcuts in `aria-keyshortcuts` form - `Control+K`, or `Meta+K` on a\n * Mac - for the button or menu item that does the same thing.\n *\n * A sequence has no form there: the attribute separates *alternatives* with a\n * space, so `G D` written into it would claim that G alone does it. Sequences\n * are left out, and a command with nothing else gets no attribute.\n */\nexport function ariaKeyShortcuts(\n keys: string | readonly string[],\n apple: boolean = isApplePlatform(),\n): string | undefined {\n const written = (typeof keys === 'string' ? [keys] : keys)\n .map((written) => parseKeys(written, apple))\n .filter((steps) => steps.length === 1)\n .map(([stroke]) => {\n const parts: string[] = []\n if (stroke!.mod) parts.push(apple ? 'Meta' : 'Control')\n if (stroke!.ctrl) parts.push('Control')\n if (stroke!.alt) parts.push('Alt')\n if (stroke!.shift) parts.push('Shift')\n parts.push(stroke!.key === '+' ? 'Plus' : stroke!.key)\n return parts.join('+')\n })\n return written.length === 0 ? undefined : written.join(' ')\n}\n"
|
|
1975
|
+
}
|
|
1976
|
+
]
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
"name": "shortcuts-dialog",
|
|
1980
|
+
"type": "registry:ui",
|
|
1981
|
+
"title": "ShortcutsDialog",
|
|
1982
|
+
"description": "The sheet of every key the application answers to, opened by `?` - written from the list of commands rather than by hand, so it cannot promise a key that does not work.",
|
|
1983
|
+
"dependencies": [
|
|
1984
|
+
"dowel-ui@^0.36.0"
|
|
1985
|
+
],
|
|
1986
|
+
"registryDependencies": [
|
|
1987
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
1988
|
+
"https://lacodda.github.io/dowel/r/commands.json",
|
|
1989
|
+
"https://lacodda.github.io/dowel/r/dialog.json",
|
|
1990
|
+
"https://lacodda.github.io/dowel/r/kbd.json"
|
|
1991
|
+
],
|
|
1992
|
+
"files": [
|
|
1993
|
+
{
|
|
1994
|
+
"path": "ui/shortcuts-dialog.tsx",
|
|
1995
|
+
"target": "@ui/shortcuts-dialog.tsx",
|
|
1996
|
+
"type": "registry:ui",
|
|
1997
|
+
"content": "import { useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'\nimport { cn } from 'dowel-ui'\nimport { Button } from './button'\nimport { groupCommands, useCommand, useCommandList, type ListedCommand } from './commands'\nimport { Dialog, DialogBody, DialogClose, DialogDescription, DialogHeader, DialogPopup, DialogTitle } from './dialog'\nimport { Kbd } from './kbd'\n\n/*\n * ShortcutsDialog.\n *\n * The sheet of every key the application answers to, opened by `?` - written\n * from the list of commands rather than by hand, so it cannot promise a key\n * that does not work.\n *\n * Every hand-written sheet of shortcuts is a version behind what the\n * application actually answers to. This one has no rows of its own: it lists\n * what is declared now, under the headings the commands give themselves, with\n * the keys they answer to after a rebinding and after a conflict. A command\n * declared by the open screen appears while that screen is open, and is gone\n * when it is not.\n *\n * It declares its own command, so `?` opens it with no wiring, the palette\n * offers it, and it lists itself - under the title the product gives it.\n *\n * It opens with the focus on the list rather than on the close button. A sheet\n * is read, not answered: with the focus on the cross, the Enter that someone\n * presses to see what happens closes it, and the arrows that should scroll\n * the list do nothing.\n */\n\n/** The id of the command the dialog declares, for a rebinding or a menu item\n * that opens it with `runCommand`. */\nexport const SHORTCUTS_COMMAND = 'shortcuts'\n\n/** One heading and its keys. A group rather than a section: a named section\n * is a landmark, and a sheet of six headings would put six landmarks into the\n * list a screen reader jumps between - on a settings page, beside the page's\n * own. */\nfunction Group({ title, commands }: { title: string | undefined; commands: ListedCommand[] }) {\n const heading = useId()\n return (\n <div\n role=\"group\"\n aria-labelledby={title === undefined ? undefined : heading}\n className=\"mb-4 break-inside-avoid last:mb-0\"\n >\n {title !== undefined && (\n <h3 id={heading} className=\"caption pb-1\">\n {title}\n </h3>\n )}\n <dl>\n {commands.map((command) => (\n <div key={command.id} className=\"flex items-baseline justify-between gap-3 py-1\">\n <dt className=\"min-w-0 text-sm\">{command.label}</dt>\n {/* Alternatives one under another: side by side, `G S` and `Ctrl ,`\n read as one shortcut of four keys. */}\n <dd className=\"flex shrink-0 flex-col items-end gap-1\">\n {command.keys.map((keys) => (\n <Kbd key={keys} keys={keys} />\n ))}\n </dd>\n </div>\n ))}\n </dl>\n </div>\n )\n}\n\n/** The list on its own, for a settings page that shows the keys in place. A\n * command with no keys has nothing to show and is left out. */\nexport function ShortcutList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {\n const groups = groupCommands(useCommandList().filter((command) => command.keys.length > 0))\n return (\n <div className={cn('gap-x-8 sm:columns-2', className)} {...props}>\n {groups.map(({ group, commands }) => (\n <Group key={group ?? ''} title={group} commands={commands} />\n ))}\n </div>\n )\n}\n\nexport interface ShortcutsDialogProps {\n /** The heading, and the name of the command that opens it: in the palette\n * and in the list itself. */\n title: string\n /** What the close button is called, for a screen reader. No default: a word\n * the component invents is a word the product cannot translate. */\n closeLabel: string\n /** The line under the heading. */\n description?: ReactNode\n /** The keys that open it. */\n keys?: string\n /** The heading its own line is listed under. */\n group?: string\n /** Controlled, for a product that opens it from somewhere else as well. */\n open?: boolean\n onOpenChange?: (open: boolean) => void\n /** After the list: what keys cannot say - a gesture with the pointer, a\n * note that letters wait while someone is typing. */\n children?: ReactNode\n}\n\nexport function ShortcutsDialog({\n title,\n closeLabel,\n description,\n keys = '?',\n group,\n open,\n onOpenChange,\n children,\n}: ShortcutsDialogProps) {\n const [own, setOwn] = useState(false)\n const body = useRef<HTMLDivElement>(null)\n const shown = open ?? own\n const setShown = (next: boolean) => {\n if (open === undefined) setOwn(next)\n onOpenChange?.(next)\n }\n\n useCommand({ id: SHORTCUTS_COMMAND, label: title, group, keys, run: () => setShown(true) })\n\n return (\n <Dialog open={shown} onOpenChange={setShown}>\n <DialogPopup size=\"lg\" initialFocus={body}>\n <DialogHeader\n action={\n <DialogClose render={<Button variant=\"icon\" size=\"icon-sm\" aria-label={closeLabel} />}>\n <CrossIcon />\n </DialogClose>\n }\n >\n <DialogTitle>{title}</DialogTitle>\n {description !== undefined && <DialogDescription>{description}</DialogDescription>}\n </DialogHeader>\n <DialogBody ref={body} tabIndex={-1}>\n {/* Clear of the ring the body draws inside its edge when it has the\n focus, which it has from the moment the sheet opens. */}\n <ShortcutList className=\"pt-1\" />\n {children}\n </DialogBody>\n </DialogPopup>\n </Dialog>\n )\n}\n\nfunction CrossIcon() {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" aria-hidden>\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" />\n </svg>\n )\n}\n"
|
|
1934
1998
|
}
|
|
1935
1999
|
]
|
|
1936
2000
|
},
|
|
@@ -1940,7 +2004,7 @@
|
|
|
1940
2004
|
"title": "SkeletonOf",
|
|
1941
2005
|
"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.",
|
|
1942
2006
|
"dependencies": [
|
|
1943
|
-
"dowel-ui@^0.
|
|
2007
|
+
"dowel-ui@^0.36.0"
|
|
1944
2008
|
],
|
|
1945
2009
|
"registryDependencies": [],
|
|
1946
2010
|
"files": [
|
|
@@ -1958,7 +2022,7 @@
|
|
|
1958
2022
|
"title": "Skeleton",
|
|
1959
2023
|
"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.",
|
|
1960
2024
|
"dependencies": [
|
|
1961
|
-
"dowel-ui@^0.
|
|
2025
|
+
"dowel-ui@^0.36.0"
|
|
1962
2026
|
],
|
|
1963
2027
|
"registryDependencies": [],
|
|
1964
2028
|
"files": [
|
|
@@ -1977,7 +2041,7 @@
|
|
|
1977
2041
|
"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\".",
|
|
1978
2042
|
"dependencies": [
|
|
1979
2043
|
"@base-ui/react",
|
|
1980
|
-
"dowel-ui@^0.
|
|
2044
|
+
"dowel-ui@^0.36.0"
|
|
1981
2045
|
],
|
|
1982
2046
|
"registryDependencies": [],
|
|
1983
2047
|
"files": [
|
|
@@ -1996,7 +2060,7 @@
|
|
|
1996
2060
|
"description": "The shape of a history, not a chart of it: no axes, no gridlines, no ticks.",
|
|
1997
2061
|
"dependencies": [
|
|
1998
2062
|
"class-variance-authority",
|
|
1999
|
-
"dowel-ui@^0.
|
|
2063
|
+
"dowel-ui@^0.36.0"
|
|
2000
2064
|
],
|
|
2001
2065
|
"registryDependencies": [],
|
|
2002
2066
|
"files": [
|
|
@@ -2015,7 +2079,7 @@
|
|
|
2015
2079
|
"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.",
|
|
2016
2080
|
"dependencies": [
|
|
2017
2081
|
"class-variance-authority",
|
|
2018
|
-
"dowel-ui@^0.
|
|
2082
|
+
"dowel-ui@^0.36.0"
|
|
2019
2083
|
],
|
|
2020
2084
|
"registryDependencies": [],
|
|
2021
2085
|
"files": [
|
|
@@ -2033,7 +2097,7 @@
|
|
|
2033
2097
|
"title": "Splash",
|
|
2034
2098
|
"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.",
|
|
2035
2099
|
"dependencies": [
|
|
2036
|
-
"dowel-ui@^0.
|
|
2100
|
+
"dowel-ui@^0.36.0"
|
|
2037
2101
|
],
|
|
2038
2102
|
"registryDependencies": [],
|
|
2039
2103
|
"files": [
|
|
@@ -2052,7 +2116,7 @@
|
|
|
2052
2116
|
"description": "The splitter primitive.",
|
|
2053
2117
|
"dependencies": [
|
|
2054
2118
|
"class-variance-authority",
|
|
2055
|
-
"dowel-ui@^0.
|
|
2119
|
+
"dowel-ui@^0.36.0"
|
|
2056
2120
|
],
|
|
2057
2121
|
"registryDependencies": [],
|
|
2058
2122
|
"files": [
|
|
@@ -2071,7 +2135,7 @@
|
|
|
2071
2135
|
"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.",
|
|
2072
2136
|
"dependencies": [
|
|
2073
2137
|
"class-variance-authority",
|
|
2074
|
-
"dowel-ui@^0.
|
|
2138
|
+
"dowel-ui@^0.36.0"
|
|
2075
2139
|
],
|
|
2076
2140
|
"registryDependencies": [],
|
|
2077
2141
|
"files": [
|
|
@@ -2090,7 +2154,7 @@
|
|
|
2090
2154
|
"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.",
|
|
2091
2155
|
"dependencies": [
|
|
2092
2156
|
"class-variance-authority",
|
|
2093
|
-
"dowel-ui@^0.
|
|
2157
|
+
"dowel-ui@^0.36.0"
|
|
2094
2158
|
],
|
|
2095
2159
|
"registryDependencies": [],
|
|
2096
2160
|
"files": [
|
|
@@ -2109,7 +2173,7 @@
|
|
|
2109
2173
|
"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.",
|
|
2110
2174
|
"dependencies": [
|
|
2111
2175
|
"class-variance-authority",
|
|
2112
|
-
"dowel-ui@^0.
|
|
2176
|
+
"dowel-ui@^0.36.0"
|
|
2113
2177
|
],
|
|
2114
2178
|
"registryDependencies": [],
|
|
2115
2179
|
"files": [
|
|
@@ -2128,7 +2192,7 @@
|
|
|
2128
2192
|
"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.",
|
|
2129
2193
|
"dependencies": [
|
|
2130
2194
|
"@base-ui/react",
|
|
2131
|
-
"dowel-ui@^0.
|
|
2195
|
+
"dowel-ui@^0.36.0"
|
|
2132
2196
|
],
|
|
2133
2197
|
"registryDependencies": [],
|
|
2134
2198
|
"files": [
|
|
@@ -2163,7 +2227,7 @@
|
|
|
2163
2227
|
"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.",
|
|
2164
2228
|
"dependencies": [
|
|
2165
2229
|
"class-variance-authority",
|
|
2166
|
-
"dowel-ui@^0.
|
|
2230
|
+
"dowel-ui@^0.36.0"
|
|
2167
2231
|
],
|
|
2168
2232
|
"registryDependencies": [
|
|
2169
2233
|
"https://lacodda.github.io/dowel/r/table-sort.json"
|
|
@@ -2185,7 +2249,7 @@
|
|
|
2185
2249
|
"dependencies": [
|
|
2186
2250
|
"@base-ui/react",
|
|
2187
2251
|
"class-variance-authority",
|
|
2188
|
-
"dowel-ui@^0.
|
|
2252
|
+
"dowel-ui@^0.36.0"
|
|
2189
2253
|
],
|
|
2190
2254
|
"registryDependencies": [],
|
|
2191
2255
|
"files": [
|
|
@@ -2204,7 +2268,7 @@
|
|
|
2204
2268
|
"description": "Free text turned into a list: type a word, press Enter, it becomes a chip.",
|
|
2205
2269
|
"dependencies": [
|
|
2206
2270
|
"class-variance-authority",
|
|
2207
|
-
"dowel-ui@^0.
|
|
2271
|
+
"dowel-ui@^0.36.0"
|
|
2208
2272
|
],
|
|
2209
2273
|
"registryDependencies": [
|
|
2210
2274
|
"https://lacodda.github.io/dowel/r/chip.json",
|
|
@@ -2215,7 +2279,7 @@
|
|
|
2215
2279
|
"path": "ui/tag-input.tsx",
|
|
2216
2280
|
"target": "@ui/tag-input.tsx",
|
|
2217
2281
|
"type": "registry:ui",
|
|
2218
|
-
"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"
|
|
2282
|
+
"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"
|
|
2219
2283
|
}
|
|
2220
2284
|
]
|
|
2221
2285
|
},
|
|
@@ -2225,7 +2289,7 @@
|
|
|
2225
2289
|
"title": "Textarea",
|
|
2226
2290
|
"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.",
|
|
2227
2291
|
"dependencies": [
|
|
2228
|
-
"dowel-ui@^0.
|
|
2292
|
+
"dowel-ui@^0.36.0"
|
|
2229
2293
|
],
|
|
2230
2294
|
"registryDependencies": [
|
|
2231
2295
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2246,7 +2310,7 @@
|
|
|
2246
2310
|
"description": "The tier primitive.",
|
|
2247
2311
|
"dependencies": [
|
|
2248
2312
|
"class-variance-authority",
|
|
2249
|
-
"dowel-ui@^0.
|
|
2313
|
+
"dowel-ui@^0.36.0"
|
|
2250
2314
|
],
|
|
2251
2315
|
"registryDependencies": [],
|
|
2252
2316
|
"files": [
|
|
@@ -2264,7 +2328,7 @@
|
|
|
2264
2328
|
"title": "TimeField",
|
|
2265
2329
|
"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`.",
|
|
2266
2330
|
"dependencies": [
|
|
2267
|
-
"dowel-ui@^0.
|
|
2331
|
+
"dowel-ui@^0.36.0"
|
|
2268
2332
|
],
|
|
2269
2333
|
"registryDependencies": [
|
|
2270
2334
|
"https://lacodda.github.io/dowel/r/input.json"
|
|
@@ -2285,7 +2349,7 @@
|
|
|
2285
2349
|
"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.",
|
|
2286
2350
|
"dependencies": [
|
|
2287
2351
|
"class-variance-authority",
|
|
2288
|
-
"dowel-ui@^0.
|
|
2352
|
+
"dowel-ui@^0.36.0"
|
|
2289
2353
|
],
|
|
2290
2354
|
"registryDependencies": [],
|
|
2291
2355
|
"files": [
|
|
@@ -2305,7 +2369,7 @@
|
|
|
2305
2369
|
"dependencies": [
|
|
2306
2370
|
"@base-ui/react",
|
|
2307
2371
|
"class-variance-authority",
|
|
2308
|
-
"dowel-ui@^0.
|
|
2372
|
+
"dowel-ui@^0.36.0"
|
|
2309
2373
|
],
|
|
2310
2374
|
"registryDependencies": [],
|
|
2311
2375
|
"files": [
|
|
@@ -2325,7 +2389,7 @@
|
|
|
2325
2389
|
"dependencies": [
|
|
2326
2390
|
"@base-ui/react",
|
|
2327
2391
|
"class-variance-authority",
|
|
2328
|
-
"dowel-ui@^0.
|
|
2392
|
+
"dowel-ui@^0.36.0"
|
|
2329
2393
|
],
|
|
2330
2394
|
"registryDependencies": [
|
|
2331
2395
|
"https://lacodda.github.io/dowel/r/layer.json"
|
|
@@ -2362,7 +2426,7 @@
|
|
|
2362
2426
|
"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.",
|
|
2363
2427
|
"dependencies": [
|
|
2364
2428
|
"class-variance-authority",
|
|
2365
|
-
"dowel-ui@^0.
|
|
2429
|
+
"dowel-ui@^0.36.0"
|
|
2366
2430
|
],
|
|
2367
2431
|
"registryDependencies": [
|
|
2368
2432
|
"https://lacodda.github.io/dowel/r/track-segments.json"
|
|
@@ -2398,7 +2462,7 @@
|
|
|
2398
2462
|
"title": "TreeView",
|
|
2399
2463
|
"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.",
|
|
2400
2464
|
"dependencies": [
|
|
2401
|
-
"dowel-ui@^0.
|
|
2465
|
+
"dowel-ui@^0.36.0"
|
|
2402
2466
|
],
|
|
2403
2467
|
"registryDependencies": [
|
|
2404
2468
|
"https://lacodda.github.io/dowel/r/tree-rows.json"
|
|
@@ -2418,7 +2482,7 @@
|
|
|
2418
2482
|
"title": "Truncate",
|
|
2419
2483
|
"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.",
|
|
2420
2484
|
"dependencies": [
|
|
2421
|
-
"dowel-ui@^0.
|
|
2485
|
+
"dowel-ui@^0.36.0"
|
|
2422
2486
|
],
|
|
2423
2487
|
"registryDependencies": [],
|
|
2424
2488
|
"files": [
|
|
@@ -2436,7 +2500,7 @@
|
|
|
2436
2500
|
"title": "VirtualList",
|
|
2437
2501
|
"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.",
|
|
2438
2502
|
"dependencies": [
|
|
2439
|
-
"dowel-ui@^0.
|
|
2503
|
+
"dowel-ui@^0.36.0"
|
|
2440
2504
|
],
|
|
2441
2505
|
"registryDependencies": [],
|
|
2442
2506
|
"files": [
|
|
@@ -2455,7 +2519,7 @@
|
|
|
2455
2519
|
"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.",
|
|
2456
2520
|
"dependencies": [
|
|
2457
2521
|
"@tauri-apps/api",
|
|
2458
|
-
"dowel-ui@^0.
|
|
2522
|
+
"dowel-ui@^0.36.0"
|
|
2459
2523
|
],
|
|
2460
2524
|
"registryDependencies": [],
|
|
2461
2525
|
"files": [
|
|
@@ -2473,7 +2537,7 @@
|
|
|
2473
2537
|
"title": "Wizard",
|
|
2474
2538
|
"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.",
|
|
2475
2539
|
"dependencies": [
|
|
2476
|
-
"dowel-ui@^0.
|
|
2540
|
+
"dowel-ui@^0.36.0"
|
|
2477
2541
|
],
|
|
2478
2542
|
"registryDependencies": [
|
|
2479
2543
|
"https://lacodda.github.io/dowel/r/button.json",
|
|
@@ -2488,6 +2552,99 @@
|
|
|
2488
2552
|
}
|
|
2489
2553
|
]
|
|
2490
2554
|
},
|
|
2555
|
+
{
|
|
2556
|
+
"name": "empty-dashboard-screen",
|
|
2557
|
+
"type": "registry:block",
|
|
2558
|
+
"title": "EmptyDashboardScreen",
|
|
2559
|
+
"description": "A dashboard on its first day: the few things to do before it has anything to show, with the next one ready to press, and the figures it will show once it does - drawn empty, so the screen says what it is for.",
|
|
2560
|
+
"dependencies": [
|
|
2561
|
+
"dowel-ui@^0.36.0"
|
|
2562
|
+
],
|
|
2563
|
+
"registryDependencies": [
|
|
2564
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2565
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2566
|
+
"https://lacodda.github.io/dowel/r/progress.json",
|
|
2567
|
+
"https://lacodda.github.io/dowel/r/stat-tile.json"
|
|
2568
|
+
],
|
|
2569
|
+
"files": [
|
|
2570
|
+
{
|
|
2571
|
+
"path": "blocks/empty-dashboard-screen.tsx",
|
|
2572
|
+
"target": "@components/blocks/empty-dashboard-screen.tsx",
|
|
2573
|
+
"type": "registry:component",
|
|
2574
|
+
"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"
|
|
2575
|
+
}
|
|
2576
|
+
]
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
"name": "list-detail-screen",
|
|
2580
|
+
"type": "registry:block",
|
|
2581
|
+
"title": "ListDetailScreen",
|
|
2582
|
+
"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.",
|
|
2583
|
+
"dependencies": [],
|
|
2584
|
+
"registryDependencies": [
|
|
2585
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
2586
|
+
"https://lacodda.github.io/dowel/r/empty-state.json",
|
|
2587
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2588
|
+
"https://lacodda.github.io/dowel/r/list-row.json",
|
|
2589
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2590
|
+
"https://lacodda.github.io/dowel/r/search-field.json"
|
|
2591
|
+
],
|
|
2592
|
+
"files": [
|
|
2593
|
+
{
|
|
2594
|
+
"path": "blocks/list-detail-screen.tsx",
|
|
2595
|
+
"target": "@components/blocks/list-detail-screen.tsx",
|
|
2596
|
+
"type": "registry:component",
|
|
2597
|
+
"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"
|
|
2598
|
+
}
|
|
2599
|
+
]
|
|
2600
|
+
},
|
|
2601
|
+
{
|
|
2602
|
+
"name": "login-screen",
|
|
2603
|
+
"type": "registry:block",
|
|
2604
|
+
"title": "LoginScreen",
|
|
2605
|
+
"description": "The page a product opens on for someone who is not signed in yet: a name, a password, a button, and the two ways it can go wrong said differently - the server refused, or the server could not be reached.",
|
|
2606
|
+
"dependencies": [
|
|
2607
|
+
"dowel-ui@^0.36.0"
|
|
2608
|
+
],
|
|
2609
|
+
"registryDependencies": [
|
|
2610
|
+
"https://lacodda.github.io/dowel/r/alert.json",
|
|
2611
|
+
"https://lacodda.github.io/dowel/r/button.json",
|
|
2612
|
+
"https://lacodda.github.io/dowel/r/field.json",
|
|
2613
|
+
"https://lacodda.github.io/dowel/r/input.json",
|
|
2614
|
+
"https://lacodda.github.io/dowel/r/panel.json",
|
|
2615
|
+
"https://lacodda.github.io/dowel/r/password-field.json"
|
|
2616
|
+
],
|
|
2617
|
+
"files": [
|
|
2618
|
+
{
|
|
2619
|
+
"path": "blocks/login-screen.tsx",
|
|
2620
|
+
"target": "@components/blocks/login-screen.tsx",
|
|
2621
|
+
"type": "registry:component",
|
|
2622
|
+
"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"
|
|
2623
|
+
}
|
|
2624
|
+
]
|
|
2625
|
+
},
|
|
2626
|
+
{
|
|
2627
|
+
"name": "settings-screen",
|
|
2628
|
+
"type": "registry:block",
|
|
2629
|
+
"title": "SettingsScreen",
|
|
2630
|
+
"description": "Settings in sections: a column of destinations, each named with a line of what is in it, beside the open section - and on a phone, the column first and then the section, with the way back.",
|
|
2631
|
+
"dependencies": [
|
|
2632
|
+
"dowel-ui@^0.36.0"
|
|
2633
|
+
],
|
|
2634
|
+
"registryDependencies": [
|
|
2635
|
+
"https://lacodda.github.io/dowel/r/frame.json",
|
|
2636
|
+
"https://lacodda.github.io/dowel/r/page-header.json",
|
|
2637
|
+
"https://lacodda.github.io/dowel/r/section-nav.json"
|
|
2638
|
+
],
|
|
2639
|
+
"files": [
|
|
2640
|
+
{
|
|
2641
|
+
"path": "blocks/settings-screen.tsx",
|
|
2642
|
+
"target": "@components/blocks/settings-screen.tsx",
|
|
2643
|
+
"type": "registry:component",
|
|
2644
|
+
"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"
|
|
2645
|
+
}
|
|
2646
|
+
]
|
|
2647
|
+
},
|
|
2491
2648
|
{
|
|
2492
2649
|
"extends": "none",
|
|
2493
2650
|
"name": "app",
|
|
@@ -2507,6 +2664,7 @@
|
|
|
2507
2664
|
"https://lacodda.github.io/dowel/r/menu.json",
|
|
2508
2665
|
"https://lacodda.github.io/dowel/r/panel.json",
|
|
2509
2666
|
"https://lacodda.github.io/dowel/r/select.json",
|
|
2667
|
+
"https://lacodda.github.io/dowel/r/shortcut.json",
|
|
2510
2668
|
"https://lacodda.github.io/dowel/r/textarea.json",
|
|
2511
2669
|
"https://lacodda.github.io/dowel/r/toast.json"
|
|
2512
2670
|
],
|
|
@@ -2568,7 +2726,7 @@
|
|
|
2568
2726
|
"path": "files/AGENTS.md",
|
|
2569
2727
|
"target": "~/AGENTS.md",
|
|
2570
2728
|
"type": "registry:file",
|
|
2571
|
-
"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"
|
|
2729
|
+
"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"
|
|
2572
2730
|
}
|
|
2573
2731
|
],
|
|
2574
2732
|
"docs": "Replace `<product>` in the accent import with your own, and add whatever else your project expects of an agent."
|