@poodle64/ui 2026.8.15 → 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.
Files changed (36) hide show
  1. package/README.md +367 -14
  2. package/dist/components/ui/button/button.svelte +13 -8
  3. package/dist/components/ui/checkbox/checkbox.svelte +23 -1
  4. package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
  5. package/dist/components/ui/form/form-button.svelte +7 -0
  6. package/dist/components/ui/form/form-button.svelte.d.ts +4 -0
  7. package/dist/components/ui/form/form-description.svelte +18 -0
  8. package/dist/components/ui/form/form-description.svelte.d.ts +4 -0
  9. package/dist/components/ui/form/form-element-field.svelte +25 -0
  10. package/dist/components/ui/form/form-element-field.svelte.d.ts +28 -0
  11. package/dist/components/ui/form/form-field-errors.svelte +31 -0
  12. package/dist/components/ui/form/form-field-errors.svelte.d.ts +8 -0
  13. package/dist/components/ui/form/form-field.svelte +25 -0
  14. package/dist/components/ui/form/form-field.svelte.d.ts +28 -0
  15. package/dist/components/ui/form/form-fieldset.svelte +16 -0
  16. package/dist/components/ui/form/form-fieldset.svelte.d.ts +27 -0
  17. package/dist/components/ui/form/form-label.svelte +25 -0
  18. package/dist/components/ui/form/form-label.svelte.d.ts +4 -0
  19. package/dist/components/ui/form/form-legend.svelte +17 -0
  20. package/dist/components/ui/form/form-legend.svelte.d.ts +4 -0
  21. package/dist/components/ui/form/index.d.ts +11 -0
  22. package/dist/components/ui/form/index.js +13 -0
  23. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  24. package/dist/components/ui/page-header/page-header.svelte +70 -33
  25. package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
  26. package/dist/components/ui/switch/index.d.ts +2 -2
  27. package/dist/components/ui/switch/index.js +1 -1
  28. package/dist/components/ui/switch/switch.svelte +38 -5
  29. package/dist/components/ui/switch/switch.svelte.d.ts +7 -1
  30. package/dist/components/ui/tabs/tabs-trigger.svelte +1 -1
  31. package/dist/format.d.ts +222 -0
  32. package/dist/format.js +422 -0
  33. package/dist/styles.css +310 -15
  34. package/package.json +16 -2
  35. package/registry/component-map.json +74 -4
  36. package/registry/component-map.md +18 -2
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.
@@ -198,7 +238,16 @@
198
238
  --color-secondary: var(--secondary, var(--ds-color-surface-1));
199
239
  --color-secondary-foreground: var(--secondary-foreground, var(--ds-color-foreground));
200
240
  --color-muted: var(--muted, var(--ds-color-surface-1));
201
- --color-accent: var(--accent, var(--ds-color-surface-2));
241
+ /* A SELECTION tint, not a surface rung (design-system#24). It was
242
+ --ds-color-surface-2 — the same rung --color-card resolves to — which made
243
+ `hover:bg-accent/50` on a card a no-op BY CONSTRUCTION: mixing a colour at
244
+ any opacity over a ground it is identical to cannot change a pixel. It also
245
+ left the menu/select/command highlight, the only thing this package's own
246
+ components use `bg-accent` for, at 1.03:1 against the popover it sits on in
247
+ light mode. A translucent tint of the accent composites over whatever
248
+ ground it lands on, so one value serves both, and it is the idiom this
249
+ package already ships for the SearchResults match highlight. */
250
+ --color-accent: var(--accent, color-mix(in oklch, var(--ds-color-primary) 12%, transparent));
202
251
  --color-accent-foreground: var(--accent-foreground, var(--ds-color-foreground));
203
252
  --color-input: var(--input, var(--ds-color-border));
204
253
 
@@ -303,6 +352,87 @@
303
352
  --ds-shell-measure-wide: 120rem;
304
353
  }
305
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
+
306
436
  @layer base {
307
437
  /**
308
438
  * The milled edge. Cards read as having depth from a hairline border plus a
@@ -788,22 +918,187 @@
788
918
  the measure rather than outside it. That is the same arithmetic as the
789
919
  `mx-auto max-w-4xl px-6` idiom this replaces, and keeping it means one
790
920
  element rather than a second wrapper nobody asked for. */
791
- .ds-shell-measure {
792
- margin-inline: auto;
793
- }
794
-
921
+ /* Two classes, because the MEASURE and the CENTRING are two decisions and
922
+ only the shell's content box wants both (design-system#22).
923
+
924
+ `.ds-measure` caps the width and nothing else. It is for a block INSIDE a
925
+ page — the paragraph of explanatory prose on a route legitimately set to
926
+ `wide`, which would otherwise inherit a 120rem measure and run to ~123
927
+ characters a line. `.ds-shell-measure` is that plus `margin-inline: auto`,
928
+ which is right for the content box and wrong for a block within a page:
929
+ applied to a set of left-anchored paragraphs it indented them ~207px away
930
+ from their own label, measured in a consumer and reverted.
931
+
932
+ Same attribute, same custom properties, so a block retunes with the shell
933
+ rather than drifting from it. That is the whole point: the alternative an
934
+ app reaches for is a local `max-w-prose` or `max-w-[72ch]`, which is a
935
+ number typed once that never hears about a retune — four apps had written
936
+ ten of them between them, and not one matched this package's own 72ch. */
937
+ .ds-measure[data-measure='prose'],
795
938
  .ds-shell-measure[data-measure='prose'] {
796
939
  max-width: var(--ds-shell-measure-prose);
797
940
  }
798
941
 
942
+ .ds-measure[data-measure='page'],
799
943
  .ds-shell-measure[data-measure='page'] {
800
944
  max-width: var(--ds-shell-measure-page);
801
945
  }
802
946
 
947
+ .ds-measure[data-measure='wide'],
803
948
  .ds-shell-measure[data-measure='wide'] {
804
949
  max-width: var(--ds-shell-measure-wide);
805
950
  }
806
951
 
952
+ .ds-shell-measure {
953
+ margin-inline: auto;
954
+ }
955
+
956
+ /* ── The prose face (design-system#32) ────────────────────────────────────
957
+ Styling for HTML the app did NOT author — rendered markdown, an extracted
958
+ document body, a description field that arrives as rich text.
959
+
960
+ Three consumers had built one of these, by three different mechanisms: a
961
+ hand-written token-based block, the Tailwind typography plugin, and a
962
+ second hand-written block that one of them had already duplicated inside
963
+ itself. Every consumer that renders a document body would otherwise derive
964
+ it a fourth time, and typography degrades worse than most things when it
965
+ is re-derived per app.
966
+
967
+ The package styles it and does NOT render it: sanitising untrusted HTML is
968
+ a security boundary, and a package cannot see the inputs the boundary is
969
+ protecting. The app owns the markdown-to-HTML seam and hands the result in.
970
+
971
+ It carries no measure. A reading measure and a reading face are two
972
+ decisions, and an app composes them:
973
+
974
+ <div class="ds-prose ds-measure" data-measure="prose">…</div>
975
+
976
+ Nothing here reaches for a colour or a length that is not a --ds-* token,
977
+ so a palette or a retune moves the whole face with it. */
978
+ .ds-prose {
979
+ color: var(--ds-color-foreground);
980
+ line-height: 1.7;
981
+ }
982
+
983
+ .ds-prose h1,
984
+ .ds-prose h2,
985
+ .ds-prose h3,
986
+ .ds-prose h4 {
987
+ font-family: var(--ds-font-display);
988
+ font-weight: 600;
989
+ line-height: 1.25;
990
+ margin-block: var(--ds-spacing-lg) var(--ds-spacing-sm);
991
+ }
992
+
993
+ /* `em` rather than the type scale: these headings sit INSIDE a document, so
994
+ they are relative to the body copy around them, not to the page's own
995
+ heading ramp. An <h1> in rendered content is not the page's <h1>. */
996
+ .ds-prose h1 {
997
+ font-size: 1.5em;
998
+ }
999
+
1000
+ .ds-prose h2 {
1001
+ font-size: 1.25em;
1002
+ }
1003
+
1004
+ .ds-prose h3,
1005
+ .ds-prose h4 {
1006
+ font-size: 1.1em;
1007
+ }
1008
+
1009
+ .ds-prose p,
1010
+ .ds-prose ul,
1011
+ .ds-prose ol,
1012
+ .ds-prose blockquote {
1013
+ margin-block: var(--ds-spacing-sm);
1014
+ }
1015
+
1016
+ .ds-prose ul,
1017
+ .ds-prose ol {
1018
+ padding-inline-start: var(--ds-spacing-lg);
1019
+ }
1020
+
1021
+ /* Restored explicitly: the preflight reset strips list markers, and rendered
1022
+ content is the one place a bullet carries meaning. */
1023
+ .ds-prose ul {
1024
+ list-style: disc;
1025
+ }
1026
+
1027
+ .ds-prose ol {
1028
+ list-style: decimal;
1029
+ }
1030
+
1031
+ .ds-prose a {
1032
+ color: var(--ds-color-primary);
1033
+ text-decoration: underline;
1034
+ text-underline-offset: 2px;
1035
+ }
1036
+
1037
+ .ds-prose strong {
1038
+ font-weight: 600;
1039
+ }
1040
+
1041
+ .ds-prose code {
1042
+ font-family: var(--ds-font-code);
1043
+ font-size: 0.9em;
1044
+ }
1045
+
1046
+ .ds-prose pre {
1047
+ background: var(--ds-color-surface-1);
1048
+ border: 1px solid var(--ds-color-border);
1049
+ border-radius: var(--ds-radius-sm);
1050
+ overflow-x: auto;
1051
+ padding: var(--ds-spacing-sm);
1052
+ }
1053
+
1054
+ .ds-prose blockquote {
1055
+ border-inline-start: 2px solid var(--ds-color-border-strong);
1056
+ color: var(--ds-color-muted-foreground);
1057
+ padding-inline-start: var(--ds-spacing-sm);
1058
+ }
1059
+
1060
+ .ds-prose hr {
1061
+ border: 0;
1062
+ border-block-start: 1px solid var(--ds-color-border);
1063
+ margin-block: var(--ds-spacing-lg);
1064
+ }
1065
+
1066
+ /* A table in an extracted document is routinely wider than the measure around
1067
+ it. It scrolls in its OWN box rather than pushing the page sideways — the
1068
+ same rule the library surfaces learned at 390px. */
1069
+ .ds-prose table {
1070
+ border-collapse: collapse;
1071
+ display: block;
1072
+ font-size: 0.9em;
1073
+ margin-block: var(--ds-spacing-md);
1074
+ max-width: 100%;
1075
+ overflow-x: auto;
1076
+ }
1077
+
1078
+ .ds-prose th,
1079
+ .ds-prose td {
1080
+ border: 1px solid var(--ds-color-border);
1081
+ padding: var(--ds-spacing-xs) var(--ds-spacing-sm);
1082
+ text-align: start;
1083
+ }
1084
+
1085
+ .ds-prose th {
1086
+ color: var(--ds-color-muted-foreground);
1087
+ font-weight: 600;
1088
+ }
1089
+
1090
+ /* A figure in a document is evidence, so it gets the surface and border a
1091
+ plate deserves, and never exceeds its column. */
1092
+ .ds-prose img {
1093
+ background: var(--ds-color-surface-2);
1094
+ border: 1px solid var(--ds-color-border);
1095
+ border-radius: var(--ds-radius-sm);
1096
+ display: block;
1097
+ height: auto;
1098
+ margin-block: var(--ds-spacing-md);
1099
+ max-width: 100%;
1100
+ }
1101
+
807
1102
  /* The content texture: the house atmosphere, painted once by the shell on its
808
1103
  own scrolling content region.
809
1104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.8.15",
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",
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/utils.d.ts",
21
21
  "svelte": "./dist/utils.js"
22
22
  },
23
+ "./format": {
24
+ "types": "./dist/format.d.ts",
25
+ "svelte": "./dist/format.js"
26
+ },
23
27
  "./*": {
24
28
  "types": "./dist/components/ui/*/index.d.ts",
25
29
  "svelte": "./dist/components/ui/*/index.js"
@@ -27,9 +31,11 @@
27
31
  },
28
32
  "peerDependencies": {
29
33
  "bits-ui": "^2.18.1",
34
+ "formsnap": "^2.0.1",
30
35
  "mode-watcher": "^1.1.0",
31
36
  "svelte": "^5.33.0",
32
- "svelte-sonner": "^1.1.1"
37
+ "svelte-sonner": "^1.1.1",
38
+ "sveltekit-superforms": "^2.30.0"
33
39
  },
34
40
  "peerDependenciesMeta": {
35
41
  "mode-watcher": {
@@ -37,6 +43,12 @@
37
43
  },
38
44
  "svelte-sonner": {
39
45
  "optional": true
46
+ },
47
+ "formsnap": {
48
+ "optional": true
49
+ },
50
+ "sveltekit-superforms": {
51
+ "optional": true
40
52
  }
41
53
  },
42
54
  "dependencies": {
@@ -58,6 +70,7 @@
58
70
  "@testing-library/svelte": "^5.4.2",
59
71
  "@types/node": "^22.20.1",
60
72
  "bits-ui": "^2.18.1",
73
+ "formsnap": "^2.0.1",
61
74
  "jsdom": "^29.1.1",
62
75
  "mode-watcher": "^1.1.0",
63
76
  "playwright": "^1.62.0",
@@ -65,6 +78,7 @@
65
78
  "svelte": "^5.56.2",
66
79
  "svelte-check": "^4.6.0",
67
80
  "svelte-sonner": "^1.1.1",
81
+ "sveltekit-superforms": "^2.30.2",
68
82
  "tailwindcss": "^4.3.2",
69
83
  "typescript": "^6.0.3",
70
84
  "vite": "^8.0.16",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.8.15",
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
- "componentCount": 54,
8
- "situationCount": 14
7
+ "componentCount": 55,
8
+ "situationCount": 15
9
9
  },
10
10
  "situations": [
11
11
  {
@@ -155,6 +155,76 @@
155
155
  }
156
156
  ]
157
157
  },
158
+ {
159
+ "key": "hand-written-form",
160
+ "title": "A form the app itself describes",
161
+ "description": "A form whose fields the app writes out itself, validated by superforms and wired for accessibility by Formsnap. These wrappers put the label/description/error rhythm and the ARIA wiring in one place; the schema-described renderer above is for the other case, where the shape arrives at runtime.",
162
+ "components": [
163
+ {
164
+ "name": "FormField",
165
+ "dir": "form",
166
+ "import": "@poodle64/ui/form",
167
+ "props": "form, name, class?",
168
+ "insteadOf": "a <div class=\"space-y-2\"> you rebuild per field, per app"
169
+ },
170
+ {
171
+ "name": "FormControl",
172
+ "dir": "form",
173
+ "import": "@poodle64/ui/form",
174
+ "props": "children, id?",
175
+ "insteadOf": "wiring id/aria-describedby/aria-invalid onto the input by hand"
176
+ },
177
+ {
178
+ "name": "FormLabel",
179
+ "dir": "form",
180
+ "import": "@poodle64/ui/form",
181
+ "props": "class?",
182
+ "insteadOf": "a <Label> that does not turn destructive when its field errors"
183
+ },
184
+ {
185
+ "name": "FormDescription",
186
+ "dir": "form",
187
+ "import": "@poodle64/ui/form",
188
+ "props": "class?",
189
+ "insteadOf": "helper text the field's aria-describedby never points at"
190
+ },
191
+ {
192
+ "name": "FormFieldErrors",
193
+ "dir": "form",
194
+ "import": "@poodle64/ui/form",
195
+ "props": "class?, errorClasses?",
196
+ "insteadOf": "hand-rendered {#each errors} blocks, styled differently in each app"
197
+ },
198
+ {
199
+ "name": "FormFieldset",
200
+ "dir": "form",
201
+ "import": "@poodle64/ui/form",
202
+ "props": "form, name, class?",
203
+ "insteadOf": "a bare <fieldset> with no error state"
204
+ },
205
+ {
206
+ "name": "FormLegend",
207
+ "dir": "form",
208
+ "import": "@poodle64/ui/form",
209
+ "props": "class?",
210
+ "insteadOf": "a <legend> styled per app"
211
+ },
212
+ {
213
+ "name": "FormElementField",
214
+ "dir": "form",
215
+ "import": "@poodle64/ui/form",
216
+ "props": "form, name, class?",
217
+ "insteadOf": "hand-indexing into an array field's errors"
218
+ },
219
+ {
220
+ "name": "FormButton",
221
+ "dir": "form",
222
+ "import": "@poodle64/ui/form",
223
+ "props": "children, variant?, size?, disabled?, class?",
224
+ "insteadOf": "remembering type=\"submit\" on every form's Button — it forwards every Button prop and sets the type"
225
+ }
226
+ ]
227
+ },
158
228
  {
159
229
  "key": "server-described-form",
160
230
  "title": "A form the server described",
@@ -220,7 +290,7 @@
220
290
  "name": "PageHeader",
221
291
  "dir": "page-header",
222
292
  "import": "@poodle64/ui/page-header",
223
- "props": "title?, eyebrow?, breadcrumbs?, subtitle?, info?, actions?",
293
+ "props": "title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?",
224
294
  "insteadOf": "a hand-written <h1> and title bar (the drift gate fails this)"
225
295
  },
226
296
  {
@@ -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.15`. 54 components, 14 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
 
@@ -83,6 +83,22 @@ _The generic titled card every route reaches for — an optional header with ico
83
83
  | --- | --- | --- | --- |
84
84
  | `Panel` | `@poodle64/ui/panel` | `children, title?, subtitle?, icon?, action?, pad?` | raw Card + CardHeader + CardTitle assembled by hand on every route |
85
85
 
86
+ ## A form the app itself describes
87
+
88
+ _A form whose fields the app writes out itself, validated by superforms and wired for accessibility by Formsnap. These wrappers put the label/description/error rhythm and the ARIA wiring in one place; the schema-described renderer above is for the other case, where the shape arrives at runtime._
89
+
90
+ | Component | Import | Key props | Reach for it instead of |
91
+ | --- | --- | --- | --- |
92
+ | `FormField` | `@poodle64/ui/form` | `form, name, class?` | a <div class="space-y-2"> you rebuild per field, per app |
93
+ | `FormControl` | `@poodle64/ui/form` | `children, id?` | wiring id/aria-describedby/aria-invalid onto the input by hand |
94
+ | `FormLabel` | `@poodle64/ui/form` | `class?` | a <Label> that does not turn destructive when its field errors |
95
+ | `FormDescription` | `@poodle64/ui/form` | `class?` | helper text the field's aria-describedby never points at |
96
+ | `FormFieldErrors` | `@poodle64/ui/form` | `class?, errorClasses?` | hand-rendered {#each errors} blocks, styled differently in each app |
97
+ | `FormFieldset` | `@poodle64/ui/form` | `form, name, class?` | a bare <fieldset> with no error state |
98
+ | `FormLegend` | `@poodle64/ui/form` | `class?` | a <legend> styled per app |
99
+ | `FormElementField` | `@poodle64/ui/form` | `form, name, class?` | hand-indexing into an array field's errors |
100
+ | `FormButton` | `@poodle64/ui/form` | `children, variant?, size?, disabled?, class?` | remembering type="submit" on every form's Button — it forwards every Button prop and sets the type |
101
+
86
102
  ## A form the server described
87
103
 
88
104
  _A config object whose shape arrives at runtime as a JSON Schema plus a JSON Forms UI Schema — the renderer walks both and dispatches to this package's widgets. Anything it cannot dispatch renders flagged, never blank._
@@ -109,7 +125,7 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
109
125
  | Component | Import | Key props | Reach for it instead of |
110
126
  | --- | --- | --- | --- |
111
127
  | `AppShell` | `@poodle64/ui/app-shell` | `nav, currentPath, brandTitle?, actions?, context?, children` | a bespoke nav rail + header layout per app |
112
- | `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, subtitle?, info?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
128
+ | `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
113
129
  | `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
114
130
 
115
131
  ## A dialog or overlay