@poodle64/ui 2026.8.17 → 2026.9.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 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, and the two things that make this package's overlays animate at all —
944
- `@custom-variant data-open` / `data-closed` (bits-ui emits `data-state="open"`,
945
- so a bare `data-open:` utility matches nothing without them) and an import of
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
@@ -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-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
24
- xs: "h-7 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
25
- sm: "h-9 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3.5 text-[0.85rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
26
- lg: 'h-11 gap-1.5 px-5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
27
- icon: 'size-10',
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-7 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
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-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
32
- 'icon-lg': 'size-11'
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-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 rounded-sm border shadow-none transition-shadow focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
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-[--border-strong] p-1 whitespace-nowrap shadow-lg duration-100 outline-none data-closed:overflow-hidden',
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,4 +1,4 @@
1
- import Root from './switch.svelte';
1
+ import Root, {} from './switch.svelte';
2
2
  export { Root,
3
3
  //
4
4
  Root as Switch };
@@ -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 h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors',
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-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
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 h-4 w-4 rounded-full shadow-lg ring-0 transition-transform',
36
- 'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
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
- declare const Switch: import("svelte").Component<Omit<Omit<SwitchPrimitive.RootProps, "child">, "children">, {}, "ref" | "checked">;
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-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=active]:border-input dark:data-[state=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-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
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 open/closed variants.
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
- Only these two need declaring. The other five shorthand data-variants this
80
- package writes `data-selected`, `data-highlighted`, `data-disabled`,
81
- `data-placeholder` (bits-ui, all emitted as empty-string-or-undefined) and
82
- `data-inset` (set by this package's own menu items) — are BARE attributes, so
83
- Tailwind's default `&[data-x]` already matches them and a declaration here
84
- would only restate it. `src/test/data-state-variants.test.ts` is what keeps
85
- that distinction honest: it enumerates every shorthand data-variant the built
86
- package ships and fails on any one whose compiled selector is not the
87
- attribute the DOM actually carries, so a new unowned variant cannot ship.
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.8.17",
3
+ "version": "2026.9.0",
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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.8.17",
4
+ "version": "2026.9.0",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 55,
@@ -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.8.17`. 55 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.0`. 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