@poodle64/ui 2026.8.17 → 2026.9.1
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 +103 -3
- package/bin/check-colour-surface.mjs +262 -0
- package/dist/components/ui/button/button.svelte +13 -8
- package/dist/components/ui/checkbox/checkbox.svelte +23 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
- package/dist/components/ui/switch/index.d.ts +2 -2
- package/dist/components/ui/switch/index.js +1 -1
- package/dist/components/ui/switch/switch.svelte +38 -5
- package/dist/components/ui/switch/switch.svelte.d.ts +7 -1
- package/dist/components/ui/tabs/tabs-trigger.svelte +1 -1
- package/dist/styles.css +131 -10
- package/package.json +5 -1
- package/registry/component-map.json +1 -1
- package/registry/component-map.md +1 -1
package/README.md
CHANGED
|
@@ -940,15 +940,25 @@ unnoticed through a full app migration (#3).
|
|
|
940
940
|
Second, what the variable layer does not cover: the `text-display` / `text-body` /
|
|
941
941
|
`text-stat` / `tracking-eyebrow` scale keys, the `.ds-edge` card treatment, the
|
|
942
942
|
`.ds-dialog-section` divider rule, the `.ds-chip` / `.ds-dot` / `.ds-ink` status
|
|
943
|
-
classes,
|
|
944
|
-
|
|
945
|
-
|
|
943
|
+
classes, the `--ds-control-*` geometry the density option turns (below), and the
|
|
944
|
+
two things that make this package's overlays animate at all — a
|
|
945
|
+
`@custom-variant` per `data-state` value (bits-ui emits `data-state="open"`, so
|
|
946
|
+
a bare `data-open:` utility matches nothing without one) and an import of
|
|
946
947
|
`tw-animate-css`, which defines `animate-in`, `fade-in-0`, `zoom-in-95` and
|
|
947
948
|
`slide-in-from-*`. Both used to be the app's job, and an app that never did the
|
|
948
949
|
job got dialogues, dropdowns, popovers and selects that opened with no
|
|
949
950
|
transition, silently. A consuming app now declares neither; one that already
|
|
950
951
|
declares them loses nothing, since the definitions are identical.
|
|
951
952
|
|
|
953
|
+
The `data-state` set covers `open`/`closed`, `checked`/`unchecked`/
|
|
954
|
+
`indeterminate` and `active`/`inactive`, so `data-checked:` and `data-active:`
|
|
955
|
+
are usable shorthands in an app's own components too. Only `open`/`closed` were
|
|
956
|
+
declared until 2026.9.0, and the gap was not theoretical: in one app every
|
|
957
|
+
checked checkbox painted no primary fill and every tab strip rendered its
|
|
958
|
+
selected trigger identically to the rest, on eleven routes, for months. Nothing
|
|
959
|
+
fails when a variant is missing — the class is in the DOM, and only the rule is
|
|
960
|
+
absent.
|
|
961
|
+
|
|
952
962
|
It holds no palette. Every value resolves through a `--ds-*` token, so choosing a
|
|
953
963
|
palette stays a matter of overriding `--ds-color-*` in your own `app.css`, and
|
|
954
964
|
`.dark` flows through untouched. Any single name can still be overridden by
|
|
@@ -964,6 +974,60 @@ differ on. An app using them registers them itself.
|
|
|
964
974
|
source scan misses them. Without it the components render unstyled (no build
|
|
965
975
|
error, no lint hit; the classes just never reach the compiled CSS).
|
|
966
976
|
|
|
977
|
+
### Control density
|
|
978
|
+
|
|
979
|
+
An app that runs its controls denser than 40px used to have exactly one move
|
|
980
|
+
available: fork `Button`. Its heights were hard-coded Tailwind classes, so no
|
|
981
|
+
token could reach them — and forking Button takes `dialog`, `alert-dialog`,
|
|
982
|
+
`command`, `input-group` and `form` with it, because each of those imports it.
|
|
983
|
+
One app carried all six for that reason, five of them otherwise identical to
|
|
984
|
+
this package's, and pinned a height on 75 call sites across 20 files.
|
|
985
|
+
|
|
986
|
+
The knob is one attribute, on any ancestor — ordinarily `<html>`, in
|
|
987
|
+
`app.html`:
|
|
988
|
+
|
|
989
|
+
```html
|
|
990
|
+
<html lang="en-AU" data-ds-density="compact"></html>
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
Two named values, `comfortable` (the default) and `compact`:
|
|
994
|
+
|
|
995
|
+
| Size | comfortable | compact |
|
|
996
|
+
| --------- | ----------- | ------- |
|
|
997
|
+
| `xs` | 28px | 24px |
|
|
998
|
+
| `sm` | 36px | 28px |
|
|
999
|
+
| `default` | 40px | 32px |
|
|
1000
|
+
| `lg` | 44px | 36px |
|
|
1001
|
+
|
|
1002
|
+
Inline padding and the trim beside an icon move with the height; the `icon-*`
|
|
1003
|
+
sizes stay squares of the same heights.
|
|
1004
|
+
|
|
1005
|
+
`compact` is not a picked scale. This package's `Input` already renders at 32px
|
|
1006
|
+
and its `Select` trigger at 32/28px, so a default-size `Button` beside an
|
|
1007
|
+
`Input` has always been 8px taller than it. At `compact` they are the same
|
|
1008
|
+
height, which is the alignment the forking app was hand-pinning.
|
|
1009
|
+
|
|
1010
|
+
The attribute is honoured wherever it appears, so a dense toolbar can carry it
|
|
1011
|
+
without the page doing so — and `data-ds-density="comfortable"` on a subtree
|
|
1012
|
+
returns that subtree to the default inside a compact page.
|
|
1013
|
+
|
|
1014
|
+
Nothing moves for an app that names no density: the `--ds-control-*` defaults
|
|
1015
|
+
reproduce the previous hard-coded classes exactly, measured in a real browser
|
|
1016
|
+
(`harness/drive.md` §"The control density ramp") rather than asserted here.
|
|
1017
|
+
Hand-tuning an individual rung remains possible and remains a deviation — the
|
|
1018
|
+
sanctioned move is choosing a named density, as choosing a palette rather than a
|
|
1019
|
+
hex value is the sanctioned move for colour.
|
|
1020
|
+
|
|
1021
|
+
### Checkbox and Switch
|
|
1022
|
+
|
|
1023
|
+
Both carry a transparent `::after` skirt that lifts the pointer target over WCAG
|
|
1024
|
+
2.5.8's 24px minimum without moving a painted pixel — a 16px checkbox has a 29px
|
|
1025
|
+
target, a 36x20px switch track a 35x31px one. Both take `aria-invalid`, which
|
|
1026
|
+
paints the destructive ring the inputs already use, so a Formsnap-wired field
|
|
1027
|
+
marks itself. `Checkbox` binds `indeterminate` and now paints the same fill as a
|
|
1028
|
+
checked box for it, rather than a dash on a transparent ground. `Switch` takes
|
|
1029
|
+
`size="sm"`, a 28x16px track that lines up with a `size="sm"` control row.
|
|
1030
|
+
|
|
967
1031
|
## Australian value formatters
|
|
968
1032
|
|
|
969
1033
|
```ts
|
|
@@ -1025,6 +1089,42 @@ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
|
|
|
1025
1089
|
too — it rides on `date-fns` in the one app that has it, and a display formatter
|
|
1026
1090
|
is not worth making that a dependency of every consumer.
|
|
1027
1091
|
|
|
1092
|
+
## The gate: `ds-check-colour-surface`
|
|
1093
|
+
|
|
1094
|
+
The package ships the gate that proves its own surface actually resolves in the
|
|
1095
|
+
consuming app:
|
|
1096
|
+
|
|
1097
|
+
```bash
|
|
1098
|
+
pnpm exec ds-check-colour-surface # entry: src/app.css
|
|
1099
|
+
pnpm exec ds-check-colour-surface --entry frontend/src/app.css # from a repo root
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
Tailwind and this package are both resolved from the STYLESHEET's directory, not
|
|
1103
|
+
the working directory, so the second form works from a repo root — which is where
|
|
1104
|
+
a pre-commit hook runs, and how the vendored copies this replaces were invoked.
|
|
1105
|
+
|
|
1106
|
+
An app's `app.css` carries two load-bearing lines, and dropping either breaks
|
|
1107
|
+
nothing any other gate can see — `build`, `lint`, `lint:css` and `check` all
|
|
1108
|
+
still pass while `bg-card`, `bg-muted`, `bg-accent`, `bg-popover` and
|
|
1109
|
+
`border-input` compile to no rule at all:
|
|
1110
|
+
|
|
1111
|
+
```css
|
|
1112
|
+
@import '@poodle64/ui/styles.css'; /* registers the surface */
|
|
1113
|
+
@source '../node_modules/@poodle64/ui/dist'; /* puts it in the scan */
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
They fail differently, so the gate asserts them differently: it compiles the
|
|
1117
|
+
semantic utilities and requires each to emit a real declaration (the `@import`),
|
|
1118
|
+
then asks the compiler which sources it resolved and requires this package's own
|
|
1119
|
+
classes to appear and compile (the `@source`). Tailwind comes from the app, not
|
|
1120
|
+
from this package's tree, so what it compiles is what the app ships.
|
|
1121
|
+
|
|
1122
|
+
It was vendored into nine apps as a byte-identical `scripts/check-colour-surface.mjs`
|
|
1123
|
+
and absent from three. If an app still carries that copy, delete it and the
|
|
1124
|
+
`lint:colour` script that calls it once the app is on this version or later —
|
|
1125
|
+
the estate's shared frontend-CI workflow prefers the shipped bin and keeps the
|
|
1126
|
+
vendored path only as a fallback.
|
|
1127
|
+
|
|
1028
1128
|
## Verifying a change
|
|
1029
1129
|
|
|
1030
1130
|
```bash
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The shadcn colour surface must actually resolve. Nothing else notices if it does not.
|
|
4
|
+
*
|
|
5
|
+
* The consuming app's `src/app.css` carries two load-bearing lines:
|
|
6
|
+
*
|
|
7
|
+
* @import '@poodle64/ui/styles.css' the shadcn semantic surface AND its
|
|
8
|
+
* Tailwind v4 `@theme inline` registration
|
|
9
|
+
* @source '.../@poodle64/ui/dist' puts the package inside Tailwind's scan
|
|
10
|
+
*
|
|
11
|
+
* Delete either and every gate an app has still passes: `pnpm build`, `lint`,
|
|
12
|
+
* `lint:css` and `check` all succeed, and the app ships with `bg-card`,
|
|
13
|
+
* `bg-muted`, `bg-accent`, `bg-popover` and `border-input` compiling to no rule
|
|
14
|
+
* at all — no build error, no lint hit, no failing test, just classes in the DOM
|
|
15
|
+
* with nothing behind them. That is poodle64/design-system#3, which reached every
|
|
16
|
+
* app in the estate before anyone saw it: dropdowns with no hover, inputs with no
|
|
17
|
+
* border, cards and popovers with no surface.
|
|
18
|
+
*
|
|
19
|
+
* The two lines fail differently, so this asserts them differently:
|
|
20
|
+
*
|
|
21
|
+
* registration compile a fixed set of semantic utilities and require each to
|
|
22
|
+
* emit a real declaration. Losing the @import kills all of them.
|
|
23
|
+
* scanning ask the compiler which sources it resolved, read the package's
|
|
24
|
+
* own dist through them, and require that its classes both appear
|
|
25
|
+
* and compile. Losing the @source leaves registration intact — the
|
|
26
|
+
* utilities still compile when named explicitly — and silently
|
|
27
|
+
* stops generating everything the shared components themselves use.
|
|
28
|
+
*
|
|
29
|
+
* It ships from the package because nine apps carried a byte-identical copy of it
|
|
30
|
+
* under `frontend/scripts/`, and three carried none: a gate that guards THIS
|
|
31
|
+
* package's surface, maintained in nine places, is the divergence it exists to
|
|
32
|
+
* stop. The only substantive change from that vendored form is where it looks —
|
|
33
|
+
* the app root comes from the working directory or `--entry`, not from the
|
|
34
|
+
* script's own location, because the script no longer lives in the app.
|
|
35
|
+
*
|
|
36
|
+
* Usage: ds-check-colour-surface [--entry <path to app.css>] (default: src/app.css)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
40
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
41
|
+
import { createRequire } from 'node:module';
|
|
42
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
43
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
44
|
+
import process from 'node:process';
|
|
45
|
+
|
|
46
|
+
/** Semantic names the package registers; each must emit a real declaration. */
|
|
47
|
+
const REGISTERED = ['bg-card', 'bg-muted', 'bg-accent', 'bg-popover', 'border-input'];
|
|
48
|
+
const PACKAGE = '@poodle64/ui';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a stylesheet the way the bundler does.
|
|
52
|
+
*
|
|
53
|
+
* `require.resolve` cannot be used: a CSS-only package exports neither a JS main
|
|
54
|
+
* nor `./package.json`. Walk `node_modules` on disk instead, from the REAL path —
|
|
55
|
+
* pnpm links each dependency into a store directory and a package's own siblings
|
|
56
|
+
* live beside it there, not under this app's symlink.
|
|
57
|
+
*/
|
|
58
|
+
function resolveStylesheet(id, base) {
|
|
59
|
+
if (id.startsWith('.') || id.startsWith('/')) return resolve(base, id);
|
|
60
|
+
const scoped = id.startsWith('@');
|
|
61
|
+
const pkgName = id
|
|
62
|
+
.split('/')
|
|
63
|
+
.slice(0, scoped ? 2 : 1)
|
|
64
|
+
.join('/');
|
|
65
|
+
const sub = id
|
|
66
|
+
.split('/')
|
|
67
|
+
.slice(scoped ? 2 : 1)
|
|
68
|
+
.join('/');
|
|
69
|
+
for (let dir = realpathSync(base); ; dir = dirname(dir)) {
|
|
70
|
+
const root = resolve(dir, 'node_modules', pkgName);
|
|
71
|
+
if (existsSync(root)) {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
|
|
73
|
+
const pick = (e) =>
|
|
74
|
+
typeof e === 'string' ? e : (e?.style ?? e?.default ?? e?.import ?? e?.require);
|
|
75
|
+
const key = sub ? `./${sub}` : '.';
|
|
76
|
+
let entry = pick(pkg.exports?.[key]) ?? pick(pkg.exports?.[`${key}.css`]);
|
|
77
|
+
if (!entry && sub) {
|
|
78
|
+
const direct = resolve(root, sub.endsWith('.css') ? sub : `${sub}.css`);
|
|
79
|
+
if (existsSync(direct)) return direct;
|
|
80
|
+
}
|
|
81
|
+
entry ??= pkg.style ?? pick(pkg.exports?.['.']) ?? pkg.main;
|
|
82
|
+
if (!entry) throw new Error(`no stylesheet entry for ${id}`);
|
|
83
|
+
return resolve(root, entry);
|
|
84
|
+
}
|
|
85
|
+
if (dirname(dir) === dir) throw new Error(`cannot resolve stylesheet ${id} from ${base}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Tailwind comes from the APP, never from this package's own tree.
|
|
91
|
+
*
|
|
92
|
+
* Under pnpm a bin runs from inside the store, where only what @poodle64/ui
|
|
93
|
+
* itself declares is reachable; the compiler that matters is the one the app
|
|
94
|
+
* builds with, and compiling against a different copy would prove nothing about
|
|
95
|
+
* the app's own output.
|
|
96
|
+
*
|
|
97
|
+
* Resolution starts at the STYLESHEET, not the working directory, so
|
|
98
|
+
* `--entry frontend/src/app.css` works from a repo root — which is where a
|
|
99
|
+
* pre-commit hook runs, and where the nine vendored copies this replaces were
|
|
100
|
+
* invoked from.
|
|
101
|
+
*/
|
|
102
|
+
async function loadCompiler(from) {
|
|
103
|
+
const require = createRequire(resolve(from, '_'));
|
|
104
|
+
let manifestPath;
|
|
105
|
+
try {
|
|
106
|
+
manifestPath = require.resolve('tailwindcss/package.json');
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`tailwindcss is not resolvable from ${from} — this gate compiles the app's own ` +
|
|
110
|
+
'stylesheet, so it needs the compiler the app builds with'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Resolve the ESM condition explicitly. `require.resolve('tailwindcss')`
|
|
115
|
+
// picks the `require` condition — Tailwind's CJS bundle — from which
|
|
116
|
+
// `compile` is not an ESM named export, so the import succeeds and hands
|
|
117
|
+
// back undefined. That reads as "compile is not a function" several frames
|
|
118
|
+
// later, which is a long way from the actual cause.
|
|
119
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
120
|
+
const map = manifest.exports?.['.'] ?? {};
|
|
121
|
+
const relative_ = typeof map === 'string' ? map : (map.import ?? map.default ?? map.require);
|
|
122
|
+
if (!relative_) throw new Error('tailwindcss declares no importable entry point');
|
|
123
|
+
const entry = resolve(dirname(manifestPath), relative_);
|
|
124
|
+
|
|
125
|
+
const module = await import(pathToFileURL(entry).href);
|
|
126
|
+
const compile = module.compile ?? module.default?.compile;
|
|
127
|
+
if (typeof compile !== 'function') {
|
|
128
|
+
throw new Error(`tailwindcss at ${entry} exports no compile(): is it v4?`);
|
|
129
|
+
}
|
|
130
|
+
return compile;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildCompiler(compile, entryPath) {
|
|
134
|
+
return compile(await readFile(entryPath, 'utf8'), {
|
|
135
|
+
base: dirname(entryPath),
|
|
136
|
+
loadStylesheet: async (id, base) => {
|
|
137
|
+
const path = resolveStylesheet(id, base);
|
|
138
|
+
return { path, base: dirname(path), content: await readFile(path, 'utf8') };
|
|
139
|
+
},
|
|
140
|
+
loadModule: async (id, base) => {
|
|
141
|
+
const req = createRequire(resolve(base, '_'));
|
|
142
|
+
const path = id.startsWith('.') ? resolve(base, id) : req.resolve(id);
|
|
143
|
+
return { path, base: dirname(path), module: (await import(pathToFileURL(path).href)).default };
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function filesUnder(dir, acc = []) {
|
|
149
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
150
|
+
const p = join(dir, e.name);
|
|
151
|
+
if (e.isDirectory()) await filesUnder(p, acc);
|
|
152
|
+
else if (/\.(svelte|js|ts)$/.test(e.name)) acc.push(p);
|
|
153
|
+
}
|
|
154
|
+
return acc;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Class-ish tokens, deliberately loose: a superset is fine, we only need some to compile. */
|
|
158
|
+
function candidatesIn(text) {
|
|
159
|
+
return new Set(
|
|
160
|
+
(text.match(/[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g) ?? []).filter(
|
|
161
|
+
(c) => c.length < 40 && !c.includes('--')
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function checkColourSurface({ cwd = process.cwd(), entry } = {}) {
|
|
167
|
+
const entryPath = resolve(cwd, entry ?? join('src', 'app.css'));
|
|
168
|
+
if (!existsSync(entryPath)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`no stylesheet at ${relative(cwd, entryPath) || entryPath} — run this from the app ` +
|
|
171
|
+
'directory, or pass --entry'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const failures = [];
|
|
176
|
+
const compiler = await buildCompiler(await loadCompiler(dirname(entryPath)), entryPath);
|
|
177
|
+
|
|
178
|
+
// 1. Registration — the @import.
|
|
179
|
+
const registeredCss = compiler.build(REGISTERED);
|
|
180
|
+
for (const utility of REGISTERED) {
|
|
181
|
+
const rule = registeredCss.match(new RegExp(`\\.${utility}\\s*\\{([^}]*)\\}`));
|
|
182
|
+
if (!rule) {
|
|
183
|
+
failures.push(`${utility}: no rule at all — the shadcn surface is not registered`);
|
|
184
|
+
} else if (!/var\(--/.test(rule[1])) {
|
|
185
|
+
failures.push(`${utility}: emits ${rule[1].trim()} — not resolving to a token`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. Scanning — the @source.
|
|
190
|
+
// Each entry is { base, pattern, negated }; the pattern is the @source argument,
|
|
191
|
+
// relative to the file that declared it. Removing the @source line empties this
|
|
192
|
+
// list entirely, which is the signal.
|
|
193
|
+
const scanned = (compiler.sources ?? [])
|
|
194
|
+
.filter((s) => !s.negated)
|
|
195
|
+
.map((s) => (typeof s === 'string' ? s : resolve(s.base, s.pattern)));
|
|
196
|
+
const packageSource = scanned.find((s) => s.includes(PACKAGE));
|
|
197
|
+
if (!packageSource) {
|
|
198
|
+
failures.push(
|
|
199
|
+
`no @source covers ${PACKAGE}: Tailwind never scans the shared components, so every ` +
|
|
200
|
+
`utility they use and this app does not compiles to nothing`
|
|
201
|
+
);
|
|
202
|
+
} else if (!existsSync(packageSource) || !statSync(packageSource).isDirectory()) {
|
|
203
|
+
failures.push(`the @source for ${PACKAGE} points at ${packageSource}, which is not a directory`);
|
|
204
|
+
} else {
|
|
205
|
+
const candidates = new Set();
|
|
206
|
+
for (const file of await filesUnder(packageSource)) {
|
|
207
|
+
for (const c of candidatesIn(await readFile(file, 'utf8'))) candidates.add(c);
|
|
208
|
+
}
|
|
209
|
+
const produced = compiler.build([...candidates]);
|
|
210
|
+
const emitted = [...candidates].filter((c) => produced.includes(`.${c}`));
|
|
211
|
+
if (emitted.length === 0) {
|
|
212
|
+
failures.push(
|
|
213
|
+
`${PACKAGE}'s own sources produced no utilities: the package is in the scan but ` +
|
|
214
|
+
`nothing it uses compiles`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { entryPath, failures };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function main(argv) {
|
|
223
|
+
const at = argv.indexOf('--entry');
|
|
224
|
+
const entry = at === -1 ? undefined : argv[at + 1];
|
|
225
|
+
if (at !== -1 && !entry) {
|
|
226
|
+
console.error('ds-check-colour-surface: --entry needs a path');
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = await checkColourSurface({ entry });
|
|
233
|
+
} catch (error) {
|
|
234
|
+
// A gate that could not run has not passed. Say which, and where it looked.
|
|
235
|
+
console.error(`ds-check-colour-surface: ${error.message}`);
|
|
236
|
+
console.error(` cwd: ${process.cwd()}`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (result.failures.length > 0) {
|
|
241
|
+
console.error('\nThe shadcn colour surface does not resolve:\n');
|
|
242
|
+
for (const f of result.failures) console.error(` - ${f}`);
|
|
243
|
+
console.error(
|
|
244
|
+
`\n${relative(process.cwd(), result.entryPath)} must keep BOTH ` +
|
|
245
|
+
`\`@import '${PACKAGE}/styles.css'\` and \`@source '../node_modules/${PACKAGE}/dist'\`,\n` +
|
|
246
|
+
`and must not re-declare the shadcn names (--card, --muted, --accent, --popover,\n` +
|
|
247
|
+
`--input) as plain custom properties: that makes the variable exist without\n` +
|
|
248
|
+
`registering it as a theme colour.\n`
|
|
249
|
+
);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(
|
|
254
|
+
`colour surface: ${REGISTERED.length} semantic utilities resolve, ${PACKAGE} is scanned`
|
|
255
|
+
);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Importable for the test suite; only a direct CLI invocation exits.
|
|
260
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
261
|
+
process.exit(await main(process.argv.slice(2)));
|
|
262
|
+
}
|
|
@@ -18,18 +18,23 @@
|
|
|
18
18
|
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
|
19
19
|
link: 'text-primary underline-offset-4 hover:underline'
|
|
20
20
|
},
|
|
21
|
+
// Every geometry value here is a `--ds-control-*` token whose default is
|
|
22
|
+
// the class it replaced (`h-10` is 2.5rem, `px-4` is 1rem, …), so a
|
|
23
|
+
// consumer that names no density renders unchanged. The tokens, the two
|
|
24
|
+
// named densities and why the ramp is a table rather than a ratio are in
|
|
25
|
+
// styles.css §"Control geometry".
|
|
21
26
|
size: {
|
|
22
27
|
default:
|
|
23
|
-
'h-
|
|
24
|
-
xs: "h-
|
|
25
|
-
sm: "h-
|
|
26
|
-
lg: 'h-
|
|
27
|
-
icon: 'size-
|
|
28
|
+
'h-(--ds-control-height-md) gap-1.5 px-(--ds-control-pad-md) has-data-[icon=inline-end]:pr-(--ds-control-pad-icon-md) has-data-[icon=inline-start]:pl-(--ds-control-pad-icon-md)',
|
|
29
|
+
xs: "h-(--ds-control-height-xs) gap-1 rounded-[min(var(--radius-md),10px)] px-(--ds-control-pad-xs) text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-(--ds-control-pad-icon-xs) has-data-[icon=inline-start]:pl-(--ds-control-pad-icon-xs) [&_svg:not([class*='size-'])]:size-3",
|
|
30
|
+
sm: "h-(--ds-control-height-sm) gap-1.5 rounded-[min(var(--radius-md),12px)] px-(--ds-control-pad-sm) text-[0.85rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-(--ds-control-pad-icon-sm) has-data-[icon=inline-start]:pl-(--ds-control-pad-icon-sm) [&_svg:not([class*='size-'])]:size-4",
|
|
31
|
+
lg: 'h-(--ds-control-height-lg) gap-1.5 px-(--ds-control-pad-lg) has-data-[icon=inline-end]:pr-(--ds-control-pad-icon-lg) has-data-[icon=inline-start]:pl-(--ds-control-pad-icon-lg)',
|
|
32
|
+
icon: 'size-(--ds-control-height-md)',
|
|
28
33
|
'icon-xs':
|
|
29
|
-
"size-
|
|
34
|
+
"size-(--ds-control-height-xs) rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
30
35
|
'icon-sm':
|
|
31
|
-
'size-
|
|
32
|
-
'icon-lg': 'size-
|
|
36
|
+
'size-(--ds-control-height-sm) rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
|
37
|
+
'icon-lg': 'size-(--ds-control-height-lg)'
|
|
33
38
|
}
|
|
34
39
|
},
|
|
35
40
|
defaultVariants: {
|
|
@@ -13,13 +13,35 @@
|
|
|
13
13
|
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> & { class?: string } = $props();
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
|
+
<!--
|
|
17
|
+
The box is 16px, which is what a checkbox has always looked like and is also
|
|
18
|
+
well under WCAG 2.5.8's 24px minimum target. The `::after` overlay is the
|
|
19
|
+
standard answer: a transparent skirt on every side takes the POINTER target to
|
|
20
|
+
a measured 29px without moving a pixel of the control, so the tick still sits where a
|
|
21
|
+
reader expects it and the row's rhythm is unchanged. `harness/drive.mjs`
|
|
22
|
+
measures that by walking outward from the centre until the hit test stops
|
|
23
|
+
returning this element, rather than by trusting the arithmetic here.
|
|
24
|
+
|
|
25
|
+
The inset is 2 rather than 1.5 for margin, not neatness: 1.5 measured 25px,
|
|
26
|
+
which clears the 24px floor at a 16px root and misses it at a 14px one — and
|
|
27
|
+
the floor is in absolute CSS pixels while the skirt is in rem. It stops 1px
|
|
28
|
+
short of a `gap-2` label, and a click that lands on the label toggles the box
|
|
29
|
+
through the label's own `for` anyway, so the enlarged area can never steal an
|
|
30
|
+
interaction from something else.
|
|
31
|
+
|
|
32
|
+
`data-indeterminate` paints the same fill as `data-checked`. It carried none
|
|
33
|
+
until now, so a tri-state checkbox rendered its dash in the FOREGROUND ink on
|
|
34
|
+
a transparent ground — the identical defect the `data-checked` mapping was
|
|
35
|
+
fixed for, one value along, and invisible for the same reason. See styles.css
|
|
36
|
+
§"The bits-ui `data-state` variants".
|
|
37
|
+
-->
|
|
16
38
|
<CheckboxPrimitive.Root
|
|
17
39
|
bind:ref
|
|
18
40
|
bind:checked
|
|
19
41
|
bind:indeterminate
|
|
20
42
|
data-slot="checkbox"
|
|
21
43
|
class={cn(
|
|
22
|
-
'peer border-border focus-visible:ring-ring data-
|
|
44
|
+
'peer border-border focus-visible:ring-ring data-checked:bg-primary data-checked:text-primary-foreground data-checked:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground data-indeterminate:border-primary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 relative size-4 shrink-0 rounded-sm border shadow-none transition-shadow after:absolute after:-inset-2 focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
|
|
23
45
|
className
|
|
24
46
|
)}
|
|
25
47
|
{...restProps}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
{sideOffset}
|
|
24
24
|
{align}
|
|
25
25
|
class={cn(
|
|
26
|
-
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-44 overflow-x-hidden overflow-y-auto rounded-lg border border-
|
|
26
|
+
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-44 overflow-x-hidden overflow-y-auto rounded-lg border border-border-strong p-1 whitespace-nowrap shadow-lg duration-100 outline-none data-closed:overflow-hidden',
|
|
27
27
|
className
|
|
28
28
|
)}
|
|
29
29
|
{...restProps}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import Root from './switch.svelte';
|
|
2
|
-
export { Root, Root as Switch };
|
|
1
|
+
import Root, { type SwitchSize } from './switch.svelte';
|
|
2
|
+
export { Root, Root as Switch, type SwitchSize };
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/** The two track sizes. `sm` lines up with a `size="sm"` control row. */
|
|
3
|
+
export type SwitchSize = 'default' | 'sm';
|
|
4
|
+
</script>
|
|
5
|
+
|
|
1
6
|
<script lang="ts">
|
|
2
7
|
import { Switch as SwitchPrimitive } from 'bits-ui';
|
|
3
8
|
import { cn, type WithoutChildrenOrChild } from '../../../utils.js';
|
|
@@ -5,9 +10,23 @@
|
|
|
5
10
|
let {
|
|
6
11
|
ref = $bindable(null),
|
|
7
12
|
checked = $bindable(false),
|
|
13
|
+
size = 'default',
|
|
8
14
|
class: className,
|
|
9
15
|
...restProps
|
|
10
|
-
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
|
16
|
+
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> & { size?: SwitchSize } = $props();
|
|
17
|
+
|
|
18
|
+
// Track, thumb and travel are one decision, so they are one table rather
|
|
19
|
+
// than three prop reads at three call sites. The travel is not a picked
|
|
20
|
+
// number: the track carries a 2px transparent border, so the thumb's run is
|
|
21
|
+
// (width - 4) - thumb, which is 16px on the default and 12px on `sm`.
|
|
22
|
+
const TRACK = {
|
|
23
|
+
default: 'h-5 w-9',
|
|
24
|
+
sm: 'h-4 w-7'
|
|
25
|
+
} as const;
|
|
26
|
+
const THUMB = {
|
|
27
|
+
default: 'size-4 data-checked:translate-x-4',
|
|
28
|
+
sm: 'size-3 data-checked:translate-x-3'
|
|
29
|
+
} as const;
|
|
11
30
|
</script>
|
|
12
31
|
|
|
13
32
|
<!--
|
|
@@ -17,23 +36,37 @@
|
|
|
17
36
|
ball stays light on the dark-first theme. The track colour comes from the
|
|
18
37
|
project tokens. An app with a non-standard root font-size (a dense console
|
|
19
38
|
running an 18px root, say) compensates in its own override layer, not here.
|
|
39
|
+
|
|
40
|
+
The track is 20px tall (16px at `sm`), under WCAG 2.5.8's 24px minimum, so a
|
|
41
|
+
transparent `::after` skirt lifts the pointer target over it. It grows the
|
|
42
|
+
BLOCK axis only: the track is already 36px wide, so there is nothing to win
|
|
43
|
+
horizontally and an inline skirt would reach into the label beside it.
|
|
44
|
+
|
|
45
|
+
The skirt is inset from the PADDING box, and this track carries a 2px
|
|
46
|
+
transparent border, so its real reach is the inset minus 2 on each side —
|
|
47
|
+
which is why the number here is not the number in the target. The target is
|
|
48
|
+
measured in `harness/drive.mjs`, not calculated.
|
|
20
49
|
-->
|
|
21
50
|
<SwitchPrimitive.Root
|
|
22
51
|
bind:ref
|
|
23
52
|
bind:checked
|
|
24
53
|
class={cn(
|
|
25
|
-
'peer inline-flex
|
|
54
|
+
'peer relative inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors',
|
|
55
|
+
'after:absolute after:inset-x-0 after:-inset-y-2',
|
|
26
56
|
'focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
|
57
|
+
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 aria-invalid:ring-3',
|
|
27
58
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
28
|
-
'data-
|
|
59
|
+
'data-checked:bg-primary data-unchecked:bg-input',
|
|
60
|
+
TRACK[size],
|
|
29
61
|
className
|
|
30
62
|
)}
|
|
31
63
|
{...restProps}
|
|
32
64
|
>
|
|
33
65
|
<SwitchPrimitive.Thumb
|
|
34
66
|
class={cn(
|
|
35
|
-
'bg-background dark:bg-foreground pointer-events-none block
|
|
36
|
-
'data-
|
|
67
|
+
'bg-background dark:bg-foreground pointer-events-none block rounded-full shadow-lg ring-0 transition-transform',
|
|
68
|
+
'data-unchecked:translate-x-0',
|
|
69
|
+
THUMB[size]
|
|
37
70
|
)}
|
|
38
71
|
/>
|
|
39
72
|
</SwitchPrimitive.Root>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
/** The two track sizes. `sm` lines up with a `size="sm"` control row. */
|
|
2
|
+
export type SwitchSize = 'default' | 'sm';
|
|
1
3
|
import { Switch as SwitchPrimitive } from 'bits-ui';
|
|
2
|
-
|
|
4
|
+
import { type WithoutChildrenOrChild } from '../../../utils.js';
|
|
5
|
+
type $$ComponentProps = WithoutChildrenOrChild<SwitchPrimitive.RootProps> & {
|
|
6
|
+
size?: SwitchSize;
|
|
7
|
+
};
|
|
8
|
+
declare const Switch: import("svelte").Component<$$ComponentProps, {}, "ref" | "checked">;
|
|
3
9
|
type Switch = ReturnType<typeof Switch>;
|
|
4
10
|
export default Switch;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
bind:ref
|
|
10
10
|
data-slot="tabs-trigger"
|
|
11
11
|
class={cn(
|
|
12
|
-
"data-
|
|
12
|
+
"data-active:bg-background dark:data-active:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:data-active:border-input dark:data-active:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-active:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
13
13
|
className
|
|
14
14
|
)}
|
|
15
15
|
{...restProps}
|
package/dist/styles.css
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
@custom-variant dark (&:is(.dark *));
|
|
64
64
|
|
|
65
65
|
/* ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
-
The bits-ui
|
|
66
|
+
The bits-ui `data-state` variants.
|
|
67
67
|
|
|
68
68
|
Same defect as `dark` above, one attribute along. This package writes ~47
|
|
69
69
|
`data-open:` / `data-closed:` utilities across the dialogue, alert-dialogue,
|
|
@@ -76,18 +76,58 @@
|
|
|
76
76
|
matches — which is why four of the five adopting apps shipped with dead
|
|
77
77
|
overlay transitions and none of them noticed.
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`data-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
`data-state` is ONE attribute carrying a dozen-odd values, and declaring two
|
|
80
|
+
of them left every other value as the same trap one value along.
|
|
81
|
+
`data-checked:` and `data-active:` compile to `&[data-checked]` /
|
|
82
|
+
`&[data-active]`, and bits-ui emits `data-state="checked"` and
|
|
83
|
+
`data-state="active"` — so a checked checkbox painted no fill and a selected
|
|
84
|
+
tab was indistinguishable from its neighbours, in exactly the silence
|
|
85
|
+
described above. Both were found in a consuming app, in a browser, months
|
|
86
|
+
after they shipped.
|
|
87
|
+
|
|
88
|
+
So the whole value set is declared, not the two that had already been
|
|
89
|
+
caught: `open`/`closed` (dialogue, menu, popover, tooltip, select),
|
|
90
|
+
`checked`/`unchecked`/`indeterminate` (checkbox, switch, menu checkbox item)
|
|
91
|
+
and `active`/`inactive` (tabs). That is a table with one rule — one
|
|
92
|
+
declaration per value bits-ui can put in `data-state` on a component this
|
|
93
|
+
package ships — rather than a growing list of exceptions, and it is the
|
|
94
|
+
whole point of fixing this at the mapping: the NEXT component to reach for
|
|
95
|
+
`data-checked:` gets a rule that matches instead of rediscovering this.
|
|
96
|
+
|
|
97
|
+
`data-active` is the one union, and it is not defensiveness. bits-ui emits
|
|
98
|
+
BOTH forms under that name: `data-state="active"` on a tabs trigger, and a
|
|
99
|
+
bare `data-active` on a navigation-menu link, a slider thumb and a pin-input
|
|
100
|
+
cell — and this package's own AppNav writes `data-active="true"` on every
|
|
101
|
+
nav row. A single-selector declaration would therefore have fixed tabs by
|
|
102
|
+
breaking the four surfaces that were already working. The other values have
|
|
103
|
+
no bare form in bits-ui 2.18.x, so they get one selector each.
|
|
104
|
+
|
|
105
|
+
`data-state="delayed-open"` (tooltip) and `data-state="selected"` (this
|
|
106
|
+
package's own table rows) keep the bracketed long form at their call sites
|
|
107
|
+
and get NO shorthand. `delayed-open` has no plausible shorthand spelling,
|
|
108
|
+
and `data-selected` is ALREADY a bare bits-ui attribute on menu and calendar
|
|
109
|
+
items — pointing that name at `[data-state='selected']` would take those
|
|
110
|
+
with it. Where a name means two things, the long form is what says which.
|
|
111
|
+
|
|
112
|
+
The remaining five shorthand data-variants this package writes —
|
|
113
|
+
`data-selected`, `data-highlighted`, `data-disabled`, `data-placeholder`
|
|
114
|
+
(bits-ui, all emitted as empty-string-or-undefined) and `data-inset` (set by
|
|
115
|
+
this package's own menu items) — are BARE attributes, so Tailwind's default
|
|
116
|
+
`&[data-x]` already matches them and a declaration here would only restate
|
|
117
|
+
it. `src/test/data-state-variants.test.ts` is what keeps that distinction
|
|
118
|
+
honest: it enumerates every shorthand data-variant the built package ships
|
|
119
|
+
and fails on any one whose compiled selector is not the attribute the DOM
|
|
120
|
+
actually carries, so a new unowned variant cannot ship. It also mounts a
|
|
121
|
+
real checkbox, switch and tab strip and reads the attributes back, so the
|
|
122
|
+
table above is pinned to bits-ui rather than to this paragraph.
|
|
88
123
|
───────────────────────────────────────────────────────────────────────────── */
|
|
89
124
|
@custom-variant data-open (&[data-state='open']);
|
|
90
125
|
@custom-variant data-closed (&[data-state='closed']);
|
|
126
|
+
@custom-variant data-checked (&[data-state='checked']);
|
|
127
|
+
@custom-variant data-unchecked (&[data-state='unchecked']);
|
|
128
|
+
@custom-variant data-indeterminate (&[data-state='indeterminate']);
|
|
129
|
+
@custom-variant data-active (&[data-state='active'], &[data-active]);
|
|
130
|
+
@custom-variant data-inactive (&[data-state='inactive']);
|
|
91
131
|
|
|
92
132
|
/* ─────────────────────────────────────────────────────────────────────────────
|
|
93
133
|
The shadcn semantic surface.
|
|
@@ -312,6 +352,87 @@
|
|
|
312
352
|
--ds-shell-measure-wide: 120rem;
|
|
313
353
|
}
|
|
314
354
|
|
|
355
|
+
/* ─────────────────────────────────────────────────────────────────────────────
|
|
356
|
+
Control geometry — the density option.
|
|
357
|
+
|
|
358
|
+
Button's height and inline padding, as tokens rather than the hard-coded
|
|
359
|
+
`h-10` / `px-4` they were. No token could reach those classes, so an app
|
|
360
|
+
wanting a denser control scale had exactly one move available: fork the
|
|
361
|
+
component. One did, and it cost six directories — `button`, `dialog`,
|
|
362
|
+
`alert-dialog`, `command`, `input-group` and `form` all stayed vendored
|
|
363
|
+
there, five of them byte-identical to this package's but for which Button
|
|
364
|
+
they import. That is the whole defect: a geometry decision with no knob
|
|
365
|
+
turns into a fork of everything downstream of it.
|
|
366
|
+
|
|
367
|
+
The values below reproduce the previous hard-coded classes EXACTLY (2.5rem
|
|
368
|
+
is h-10, 1rem is px-4, and so on down the ramp), so a consumer that names
|
|
369
|
+
nothing renders to the same pixel. `harness/additivity.mjs` is what proves
|
|
370
|
+
that rather than this sentence.
|
|
371
|
+
|
|
372
|
+
An app does not set these by hand. It names a density on any ancestor —
|
|
373
|
+
ordinarily `<html>` — and the preset moves the whole ramp:
|
|
374
|
+
|
|
375
|
+
<html data-ds-density="compact">
|
|
376
|
+
|
|
377
|
+
Two named values, `comfortable` (the default) and `compact`, and the
|
|
378
|
+
attribute is honoured wherever it appears, so a single dense toolbar can
|
|
379
|
+
carry it without the page doing so. Hand-tuning a rung stays possible for
|
|
380
|
+
the app that must, but it is a deviation rather than the affordance: the
|
|
381
|
+
sanctioned move is choosing a named density, exactly as choosing a palette
|
|
382
|
+
rather than a hex value is the sanctioned move for colour.
|
|
383
|
+
|
|
384
|
+
`compact` is not a picked number. This package's Input already renders at
|
|
385
|
+
`h-8` and its Select trigger at `h-8`/`h-7`, so a default-size Button next
|
|
386
|
+
to an Input has been 8px taller than it for as long as both have shipped.
|
|
387
|
+
The compact ramp lands `md` on 2rem and `sm` on 1.75rem — the input and the
|
|
388
|
+
select trigger exactly — so the density an app was hand-pinning on 75 call
|
|
389
|
+
sites is the one that makes a form row line up.
|
|
390
|
+
|
|
391
|
+
`comfortable` shares the `:root` selector rather than restating the ramp;
|
|
392
|
+
it is there so a subtree can opt back OUT of a compact page. Both density
|
|
393
|
+
rules carry the same specificity as `:root` and are declared after it, so a
|
|
394
|
+
page-level attribute wins, and they declare on the element that carries the
|
|
395
|
+
attribute rather than at `:root`, so a scoped one reaches only its subtree.
|
|
396
|
+
───────────────────────────────────────────────────────────────────────────── */
|
|
397
|
+
:root,
|
|
398
|
+
[data-ds-density='comfortable'] {
|
|
399
|
+
--ds-control-height-xs: 1.75rem;
|
|
400
|
+
--ds-control-height-sm: 2.25rem;
|
|
401
|
+
--ds-control-height-md: 2.5rem;
|
|
402
|
+
--ds-control-height-lg: 2.75rem;
|
|
403
|
+
|
|
404
|
+
--ds-control-pad-xs: 0.625rem;
|
|
405
|
+
--ds-control-pad-sm: 0.875rem;
|
|
406
|
+
--ds-control-pad-md: 1rem;
|
|
407
|
+
--ds-control-pad-lg: 1.25rem;
|
|
408
|
+
|
|
409
|
+
/* The inline padding on an edge an icon sits against: an icon is already
|
|
410
|
+
optical whitespace, so it needs less room beside it than a glyph does. A
|
|
411
|
+
separate rung rather than a subtraction from the pad above, because the
|
|
412
|
+
trim is not one ratio — sm gives back 0.375rem where md gives back
|
|
413
|
+
0.25rem — and folding it into a calc would have changed sm for every
|
|
414
|
+
consumer to buy a shorter table. */
|
|
415
|
+
--ds-control-pad-icon-xs: 0.375rem;
|
|
416
|
+
--ds-control-pad-icon-sm: 0.5rem;
|
|
417
|
+
--ds-control-pad-icon-md: 0.75rem;
|
|
418
|
+
--ds-control-pad-icon-lg: 0.75rem;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
[data-ds-density='compact'] {
|
|
422
|
+
--ds-control-height-xs: 1.5rem;
|
|
423
|
+
--ds-control-height-sm: 1.75rem;
|
|
424
|
+
--ds-control-height-md: 2rem;
|
|
425
|
+
--ds-control-height-lg: 2.25rem;
|
|
426
|
+
--ds-control-pad-xs: 0.5rem;
|
|
427
|
+
--ds-control-pad-sm: 0.625rem;
|
|
428
|
+
--ds-control-pad-md: 0.75rem;
|
|
429
|
+
--ds-control-pad-lg: 1rem;
|
|
430
|
+
--ds-control-pad-icon-xs: 0.25rem;
|
|
431
|
+
--ds-control-pad-icon-sm: 0.375rem;
|
|
432
|
+
--ds-control-pad-icon-md: 0.5rem;
|
|
433
|
+
--ds-control-pad-icon-lg: 0.625rem;
|
|
434
|
+
}
|
|
435
|
+
|
|
315
436
|
@layer base {
|
|
316
437
|
/**
|
|
317
438
|
* The milled edge. Cards read as having depth from a hairline border plus a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.9.1",
|
|
4
4
|
"description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
+
"bin",
|
|
13
14
|
"dist",
|
|
14
15
|
"registry"
|
|
15
16
|
],
|
|
@@ -29,6 +30,9 @@
|
|
|
29
30
|
"svelte": "./dist/components/ui/*/index.js"
|
|
30
31
|
}
|
|
31
32
|
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"ds-check-colour-surface": "./bin/check-colour-surface.mjs"
|
|
35
|
+
},
|
|
32
36
|
"peerDependencies": {
|
|
33
37
|
"bits-ui": "^2.18.1",
|
|
34
38
|
"formsnap": "^2.0.1",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @poodle64/ui — situation → component map
|
|
2
2
|
|
|
3
3
|
<!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
|
|
4
|
-
Generated from `@poodle64/ui@2026.
|
|
4
|
+
Generated from `@poodle64/ui@2026.9.1`. 55 components, 15 situations.
|
|
5
5
|
|
|
6
6
|
**Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
|
|
7
7
|
|