@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/README.md CHANGED
@@ -31,6 +31,8 @@ shadcn surface follows; a consuming app writes no alias layer of its own.
31
31
  ```text
32
32
  src/lib/
33
33
  utils.ts cn() (clsx + tailwind-merge) and the shared TS helper types
34
+ format.ts the Australian value formatters: money, dates, percentages,
35
+ numbers — en-AU, AUD, Australia/Brisbane
34
36
  styles.css the component stylesheet: scale keys, .ds-edge, .ds-chip/.ds-dot,
35
37
  the dialogue-section divider rule
36
38
  components/ui/ one directory per component: the shadcn-svelte primitives
@@ -81,7 +83,7 @@ rather than rebuilding it:
81
83
 
82
84
  | Import | What it is |
83
85
  | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | `page-header` | The only page-title pattern: optional `breadcrumbs` snippet, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
86
+ | `page-header` | The only page-title pattern: optional `breadcrumbs` and `icon` snippets, eyebrow, an optional title, one clamped subtitle, an `info` tooltip, a `meta` row and an `actions` slot. Omit `title` for a header that is a breadcrumb bar. |
85
87
  | `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
86
88
  | `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
87
89
  | `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. |
@@ -147,7 +149,7 @@ That is the whole minimum. Everything below is optional.
147
149
  | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
148
150
  | `nav` | Bare `NavItem`s, `NavGroup`s, or a mix. Consecutive bare items collapse into one run; an emptied group renders nothing. |
149
151
  | `currentPath` | Active state, and closing the mobile nav on navigation. |
150
- | `collapsible`, `collapsed` | An icon-only rail collapse toggle whose state binds out so an app can persist it. |
152
+ | `collapsible`, `collapsed` | An icon-only rail collapse toggle whose state binds out so an app can persist it. `collapsible` defaults to `true`. |
151
153
  | `brand` / `brandTitle` + `brandMark` / `homeHref` | Full control of the lockup, or the wordmark-plus-mark shorthand. |
152
154
  | `identity` | The signed-in surface. Rendered once, at the end of the top bar. |
153
155
  | `context`, `actions` | Leading and trailing top-bar slots: a store/tenant switcher, app-level action buttons. |
@@ -155,7 +157,7 @@ That is the whole minimum. Everything below is optional.
155
157
  | `onSearch`, `searchLabel`, `searchShortcut` | Provide `onSearch` to render the search affordance at all. |
156
158
  | `themeToggle`, `onToggleTheme` | Defaults to `mode-watcher`. Set `themeToggle={false}` when the app puts theming inside its own user menu. |
157
159
  | `measure` | How wide the page body may get, from a named scale. Defaults to `full` (no cap). |
158
- | `texture` | The house atmosphere on the content region. Defaults to `none`. |
160
+ | `texture` | The house atmosphere on the content region. Defaults to `grid`; `none` is the opt-out. |
159
161
  | `padded`, `mainClass` | Padding, and extra classes on the scrolling content container. |
160
162
 
161
163
  ### Nested navigation
@@ -343,36 +345,66 @@ Set it in the layout, not the page. A page reaching for `measure` is the habit
343
345
  this replaces; a route group that genuinely differs (a docs section inside an
344
346
  app of dashboards) gets its own layout, which is where a shared decision belongs.
345
347
 
348
+ #### A block inside the page: `.ds-measure`
349
+
350
+ A route legitimately set to `wide` — a dashboard, a table, a card grid — often
351
+ also carries a paragraph of explanatory prose, and that prose inherits the wide
352
+ measure. Cap the block, not the route:
353
+
354
+ ```svelte
355
+ <p class="ds-measure" data-measure="prose">Explanatory running text…</p>
356
+ ```
357
+
358
+ Same attribute and same custom properties as the shell's own measure, so the
359
+ block retunes when the scale does. That is the point of it: the alternative an
360
+ app reaches for is a local `max-w-prose` or `max-w-[72ch]`, a number typed once
361
+ that never hears about a retune — four apps had written ten of them between
362
+ them and not one matched this package's own `72ch`.
363
+
364
+ `.ds-measure` caps and nothing else. `.ds-shell-measure`, which the shell puts
365
+ on its own content box, is that plus `margin-inline: auto` — right for a content
366
+ box, wrong for a block within a page, where it indented a set of left-anchored
367
+ paragraphs ~207px away from their own label. Both halves are gated in
368
+ `harness/drive.mjs` at 2560px on a `wide` route: the cap binds, the block stays
369
+ on the page's own left edge, and it follows a retune of
370
+ `--ds-shell-measure-prose`.
371
+
346
372
  ### The content texture
347
373
 
348
374
  The house atmosphere — a faint dot-grid floor with a soft accent vignette in the
349
375
  top-right — painted once, by the shell, on the region that scrolls:
350
376
 
351
377
  ```svelte
352
- <AppShell {nav} currentPath={page.url.pathname} texture="grid">
378
+ <AppShell {nav} currentPath={page.url.pathname}>
353
379
  {@render children()}
354
380
  </AppShell>
355
381
  ```
356
382
 
357
383
  | Value | What it paints | Reach for it when |
358
384
  | ------ | ------------------------------------------------------- | ------------------------------------------------------------------------ |
359
- | `grid` | a dot-grid floor plus one accent vignette in the corner | the app wants the house "instrument-grade" surface rather than flat page |
360
- | `none` | nothing at all | the default |
385
+ | `grid` | a dot-grid floor plus one accent vignette in the corner | the default the house "instrument-grade" surface |
386
+ | `none` | nothing at all | an app arguing a deliberate exception |
361
387
 
362
- `none` is the default, so a shell that does not mention `texture` renders exactly
363
- as it did before the prop existed. Verified, not assumed, and reproducibly so:
388
+ `grid` is the default since `2026.8.8`, so a shell that does not mention
389
+ `texture` wears the house atmosphere. It shipped `none`-by-default in `2026.8.4`
390
+ and the estate's answer to opt-in was measured a fortnight later: five of nine
391
+ stamped apps wore it, three of them through a hand-rolled `*-dotgrid` class in
392
+ their own `app.css` under three names. An opt-in house style measures who
393
+ remembered, not what the house looks like.
394
+
395
+ `texture="none"` is the complete opt-out and renders the region exactly as it
396
+ was before the feature existed — gated, not assumed:
364
397
 
365
398
  ```sh
366
399
  node harness/additivity.mjs ui-v2026.8.3
367
400
  # 15 surface/viewport pairs, 105 compared fields (including the screenshot hash)
368
- # IDENTICAL on every field and every pixel the change is additive.
401
+ # IDENTICAL on every field and every pixel against an explicit texture="none".
369
402
  ```
370
403
 
371
404
  `harness/additivity.mjs` builds the package at any base ref in a throwaway git
372
405
  worktree, renders the five surfaces an existing consumer already has at 2560px,
373
406
  1440px and 360px, and diffs the markup, both attribute sets, the computed box and
374
- background properties, the geometry and the rendered pixels. Adoption is
375
- deliberate, one app at a time.
407
+ background properties, the geometry and the rendered pixels.
376
408
 
377
409
  **Why it lives on the shell rather than being a class an app applies.** Because
378
410
  that is the entire defect it fixes. Two apps had built this same picture
@@ -434,6 +466,49 @@ Three things worth knowing before you set it:
434
466
 
435
467
  Set it in the layout, once. That is the whole point of the prop.
436
468
 
469
+ ### The page header: `icon`, `meta`, and what `eyebrow` is for
470
+
471
+ ```svelte
472
+ <PageHeader title="Rivers Family Trust" subtitle="Deed of variation">
473
+ {#snippet icon()}<FileText />{/snippet}
474
+ {#snippet meta()}
475
+ <span>Opened 12/03/2026</span>
476
+ <StatusBadge status="success" label="Active" />
477
+ {/snippet}
478
+ {#snippet actions()}<Button>Edit</Button>{/snippet}
479
+ </PageHeader>
480
+ ```
481
+
482
+ `icon` takes the **glyph alone**; the tinted square around it — its size, its
483
+ radius, its alignment against the title — belongs to this component. That split
484
+ is the whole reason the slot exists rather than each app placing its own icon:
485
+ two apps had built the square and picked two sizes for it. `meta` is a wrapped
486
+ row of facts under the title, in muted small text.
487
+
488
+ Both are additive. A header that names neither renders exactly as it did
489
+ before, asserted rather than assumed.
490
+
491
+ They were promoted on **duplication, not on request**: three apps had
492
+ hand-rolled the meta row and two the icon square, and one of them had
493
+ reimplemented this entire component locally to get them, across 38 of its 49
494
+ page headers. One app wanting something does not earn a shared slot; three
495
+ apps having already built it does.
496
+
497
+ #### `eyebrow` is an exception, not a slot to fill
498
+
499
+ `eyebrow` renders a small uppercase kicker above the title, and a kicker above a
500
+ heading is one of the more reliable visual tells of generated UI. In an app with
501
+ a persistent nav rail it usually restates the section the rail already
502
+ highlights, and it costs vertical space above every title in the app.
503
+
504
+ It is kept because it is genuinely load-bearing in a few places, not because it
505
+ is a good default — one audited app passed it on **all 17** of its page headers,
506
+ including a home surface whose kicker read "AIR 6015 · CASPO Workbench" directly
507
+ above a title reading "Workbench". Before reaching for it, check whether the
508
+ fact belongs in `subtitle` (a short line), `info` (an explanation), `meta` (a
509
+ fact about the page) or `breadcrumbs` (where you are). If one of those fits, use
510
+ it.
511
+
437
512
  ### The active nav row: primary is a fill, never an ink
438
513
 
439
514
  The active row is marked with a 12% `--ds-color-primary` tint, a primary edge
@@ -464,6 +539,105 @@ Check that at ≥4.5:1 against `--ds-color-surface-1` (the chrome), not against
464
539
  darker of the two. `harness/drive.mjs` gates the default path across three
465
540
  palettes in both themes on every run.
466
541
 
542
+ ### `.ds-prose`: HTML the app did not author
543
+
544
+ Rendered markdown, an extracted document body, a rich-text field — content whose
545
+ tags the app did not write and cannot style at the call site:
546
+
547
+ ```svelte
548
+ <div class="ds-prose ds-measure" data-measure="prose">
549
+ {@html sanitised}
550
+ </div>
551
+ ```
552
+
553
+ Headings take the display face at sizes relative to the body copy around them
554
+ (an `<h1>` inside a document is not the page's `<h1>`), lists keep the markers
555
+ the preflight reset strips, links take the accent, code takes the mono face, and
556
+ a table wider than the measure **scrolls in its own box rather than taking the
557
+ page sideways** — gated at 360px, and driven red by removing its `overflow-x`,
558
+ which pushes 901px into the content region.
559
+
560
+ The face carries no measure. A reading width and a reading face are two
561
+ decisions, so compose it with [`.ds-measure`](#a-block-inside-the-page-ds-measure)
562
+ as above.
563
+
564
+ **The package styles this content; it does not render it.** Sanitising untrusted
565
+ HTML is a security boundary, and a package cannot see the inputs the boundary is
566
+ protecting — the app owns the markdown-to-HTML seam and hands the result in.
567
+
568
+ It is here because three consumers had each built one, by three different
569
+ mechanisms — a hand-written token-based block, the Tailwind typography plugin,
570
+ and a second hand-written block one of them had already duplicated inside
571
+ itself. Typography degrades worse than most things when it is re-derived per
572
+ app.
573
+
574
+ ### Depth: the ladder and one hairline, not an elevation scale
575
+
576
+ A raised surface reads as raised from **the surface ladder plus a 1px inner
577
+ highlight**, applied by `.ds-edge` — which `DetailPanel`, `AppDialog`, `Panel`,
578
+ `StatCard`, `StatList`, `EmptyState`, `ErrorState` and `DataTableTanstack`
579
+ already carry, so most apps never write it. There is one knob:
580
+
581
+ ```css
582
+ :root {
583
+ /* The drop shadow under .ds-edge. Neutral translucent black in both modes —
584
+ a shadow is an absence of light, not a palette colour. */
585
+ --ds-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.12);
586
+ }
587
+ ```
588
+
589
+ **There is deliberately no `sm`/`md`/`lg` elevation scale**, and that is a
590
+ decision rather than a gap. A hairline border under a wide soft shadow is a
591
+ recognised generated-UI tell, and a named ladder of shadows invites exactly it —
592
+ so depth is declared once per surface, by the rung it sits on, and the edge does
593
+ the rest.
594
+
595
+ Measured across nine consumers before deciding: **two** declared their own
596
+ shadows, not the "every app" the case for a scale assumed. One of the two
597
+ already points `--ds-shadow-sm` at its own value, which is the supported path
598
+ working as intended. The other had rebuilt `.ds-edge`'s exact formula under a
599
+ different local name, which is a discoverability problem this section exists to
600
+ fix, not an argument for more tokens.
601
+
602
+ Motion is the same answer for a stronger reason: **no** consumer declares a
603
+ duration or easing scale. What three of them do share, verbatim, is a
604
+ `prefers-reduced-motion` block — an accessibility guard rather than a scale, and
605
+ a better candidate for sharing than any easing curve.
606
+
607
+ ### Making a surface interactive: `hover:bg-accent`
608
+
609
+ A clickable card, row or tile takes the ordinary shadcn treatment, and it works:
610
+
611
+ ```svelte
612
+ <Card class="hover:bg-accent/50 cursor-pointer">…</Card>
613
+ ```
614
+
615
+ `bg-accent` is a **12% tint of `--ds-color-primary`**, the same fill the active
616
+ nav row wears — so a hover reads as the same language as a selection, and it
617
+ follows an app's own accent with no per-app CSS. Reach for it rather than
618
+ writing a local `hover:border-primary`, which is what four apps had each
619
+ arrived at separately.
620
+
621
+ It has not always worked, and the way it failed is worth keeping. Until
622
+ `2026.8.17`, `--color-accent` and `--color-card` both resolved to
623
+ `--ds-color-surface-2` — the same rung — so `hover:bg-accent/50` on a card
624
+ mixed a colour at 50% over a ground identical to it and could not move a pixel.
625
+ Every app that made a card clickable shipped a control with no hover
626
+ affordance, and nothing caught it: it compiled, type-checked, passed the
627
+ component tests, and satisfied every contrast check on text. It was wrong only
628
+ when a human moved a mouse.
629
+
630
+ So the gate for it is a value comparison in a real browser rather than a
631
+ structural one — `harness/drive.mjs` drives a pointer onto a card in both
632
+ colour schemes, composites the resting and hovered fills, and asserts they
633
+ differ, that the difference is large enough to see, and that **the hover fill
634
+ moves when the app retunes `--ds-color-primary`**. That last check is the one
635
+ that matters: the first two passed against the broken build.
636
+
637
+ Depth is not the affordance. The household design language separates surfaces
638
+ with the ladder and a hairline edge, not with a drop shadow that grows on
639
+ hover — see `packages/design-tokens/README.md`.
640
+
467
641
  ### Measuring the content region
468
642
 
469
643
  About overflow, not width — for how wide the body is allowed to get, see
@@ -508,6 +682,60 @@ but the same class on the `<th>` collapses the heading over its neighbour:
508
682
  { accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }
509
683
  ```
510
684
 
685
+ ## Hand-written forms
686
+
687
+ The shadcn-svelte Formsnap wrapper set, over `sveltekit-superforms`:
688
+
689
+ ```svelte
690
+ <script lang="ts">
691
+ import * as Form from '@poodle64/ui/form';
692
+ import { Input } from '@poodle64/ui/input';
693
+ </script>
694
+
695
+ <Form.Field {form} name="title">
696
+ <Form.Control>
697
+ {#snippet children({ props })}
698
+ <Form.Label>Title</Form.Label>
699
+ <Input {...props} bind:value={$formData.title} />
700
+ {/snippet}
701
+ </Form.Control>
702
+ <Form.Description>What the record is called.</Form.Description>
703
+ <Form.FieldErrors />
704
+ </Form.Field>
705
+ <Form.Button>Save</Form.Button>
706
+ ```
707
+
708
+ `Field`, `Control`, `Label`, `Description`, `FieldErrors`, `Fieldset`, `Legend`,
709
+ `ElementField` and `Button`, each also exported under a `Form`-prefixed alias
710
+ (`FormField`, `FormLabel`, …) for a flat import.
711
+
712
+ `formsnap` and `sveltekit-superforms` are **optional peer dependencies**. An app
713
+ that renders no form installs neither and the other 54 components are
714
+ unaffected; an app that does already has both, since these wrappers are useless
715
+ without them.
716
+
717
+ This is the sibling of [Server-described forms](#server-described-forms) below,
718
+ and the two answer different questions: reach for these when the app knows the
719
+ fields at build time, and for `SchemaForm` when the shape arrives at runtime.
720
+
721
+ **Why it lives here.** Three apps had vendored the same nine files. The diff
722
+ between two of them was quote style; between those and the third, which package
723
+ the shared `cn` and `Label` were imported from. Nothing had diverged — but every
724
+ one of them owned its own copy of the ARIA wiring, so a fix to how an error is
725
+ announced, or to the `aria-describedby` chain, landed once per app or not at
726
+ all.
727
+
728
+ That is what the tests assert, rather than the markup: the label resolves `for`
729
+ to the control's generated id, `aria-describedby` reaches both the description
730
+ and the error node, an errored field flips `aria-invalid` and marks the label
731
+ `data-fs-error`, and `Form.Button` is `type="submit"` without the call site
732
+ saying so. Each was driven red before being kept. A test on the class strings
733
+ would have passed against all three copies while any one of them quietly stopped
734
+ pointing at its own error node.
735
+
736
+ Consumers drop their local `form/` directory at their next frontend change set
737
+ — there is no forced sweep, and their own `check-ui-drift.mjs` will name it.
738
+
511
739
  ## Server-described forms
512
740
 
513
741
  `<SchemaForm>` renders a config object whose shape arrives at runtime. It is the
@@ -712,15 +940,25 @@ unnoticed through a full app migration (#3).
712
940
  Second, what the variable layer does not cover: the `text-display` / `text-body` /
713
941
  `text-stat` / `tracking-eyebrow` scale keys, the `.ds-edge` card treatment, the
714
942
  `.ds-dialog-section` divider rule, the `.ds-chip` / `.ds-dot` / `.ds-ink` status
715
- classes, and the two things that make this package's overlays animate at all —
716
- `@custom-variant data-open` / `data-closed` (bits-ui emits `data-state="open"`,
717
- 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
718
947
  `tw-animate-css`, which defines `animate-in`, `fade-in-0`, `zoom-in-95` and
719
948
  `slide-in-from-*`. Both used to be the app's job, and an app that never did the
720
949
  job got dialogues, dropdowns, popovers and selects that opened with no
721
950
  transition, silently. A consuming app now declares neither; one that already
722
951
  declares them loses nothing, since the definitions are identical.
723
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
+
724
962
  It holds no palette. Every value resolves through a `--ds-*` token, so choosing a
725
963
  palette stays a matter of overriding `--ds-color-*` in your own `app.css`, and
726
964
  `.dark` flows through untouched. Any single name can still be overridden by
@@ -736,6 +974,121 @@ differ on. An app using them registers them itself.
736
974
  source scan misses them. Without it the components render unstyled (no build
737
975
  error, no lint hit; the classes just never reach the compiled CSS).
738
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
+
1031
+ ## Australian value formatters
1032
+
1033
+ ```ts
1034
+ import { formatCurrency, formatDate, formatPercentage } from '@poodle64/ui/format';
1035
+ ```
1036
+
1037
+ Money, dates and times, percentages and plain numbers, in `en-AU` / AUD /
1038
+ `Australia/Brisbane`. No dependencies and no DOM — it is `Intl` and arithmetic,
1039
+ so it runs in a load function as happily as in a component.
1040
+
1041
+ | Function | What it does |
1042
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
1043
+ | `formatCurrency(dollars, opts)` | `$1,234.56`. `decimals` (default 2), `currency` (default AUD), `fallback`. |
1044
+ | `formatCurrencyFromCents(cents, opts)` | The same, for money stored as integer cents. |
1045
+ | `formatCurrencyString(str, opts)` | Groups a money STRING without ever parsing it to a float — for a figure that reaches a tax return. |
1046
+ | `compactCurrency(value, opts)` | `$1.1m` / `$12k`, for a chart axis or a dense tile. |
1047
+ | `isNegativeMoney(value)` | The sign, without a parse, for choosing a tone class. |
1048
+ | `dollarsToCents(dollars)` | Whole cents. |
1049
+ | `formatNumber(value, opts)` | `1,234,567`. |
1050
+ | `formatPercentage(value, opts)` | Value is already in percentage POINTS: `4.5` → `4.5%`. |
1051
+ | `formatRatioAsPercentage(value, opts)` | Value is a 0–1 RATIO: `0.045` → `4.5%`. |
1052
+ | `formatDate(value, opts)` | `19 Dec 2024`, `19 December 2024` (`format: 'long'`) or `19/12/2024` (`format: 'numeric'`). |
1053
+ | `formatDateTime(value, opts)` | The same plus a 24-hour time: `19 Dec 2024, 14:05`. |
1054
+ | `parseApiDate(iso)` | Reads an offset-less timestamp as UTC — the naive-UTC backend trap below. |
1055
+
1056
+ Four decisions worth knowing before a migration, because each was settled
1057
+ against an app that had decided it the other way:
1058
+
1059
+ - **A missing value renders `N/A`**, and every formatter takes `fallback` to say
1060
+ otherwise. `-` is deliberately not the default: beside a money column it reads
1061
+ as a minus sign.
1062
+ - **Money keeps its cents by default.** Dropping them is a loss of fidelity the
1063
+ caller asks for (`{ decimals: 0 }`), not the default that rounds $1,234.56 up
1064
+ to $1,235.
1065
+ - **A negative carries its sign outside the symbol** — `-$1,234.56`, never
1066
+ `$-1,234.56`.
1067
+ - **`formatPercentage` and `formatRatioAsPercentage` are named apart on
1068
+ purpose.** The two apps had settled on `formatPercent` meaning opposite
1069
+ things; reusing either spelling for both would make a 100x error a
1070
+ one-character mistake.
1071
+
1072
+ Timestamps are read in `Australia/Brisbane` unless a `timeZone` is passed. The
1073
+ household's books are kept in AEST, so a laptop in another zone should not
1074
+ renumber them — and it makes the output deterministic under CI. A date-only
1075
+ value (`YYYY-MM-DD`) gets no zone conversion at all: a date is not an instant,
1076
+ and converting one is how a booking dated the 1st shows as the 31st.
1077
+
1078
+ `parseApiDate` exists because a backend that stores naive UTC
1079
+ (`datetime.now(UTC).replace(tzinfo=None)`) serialises it with no offset, and
1080
+ JavaScript reads that as LOCAL time. In Brisbane that lands every stored moment
1081
+ ten hours early, so anything after 2pm UTC shows the wrong DAY. `formatDate` and
1082
+ `formatDateTime` already go through it; call it directly when you need the
1083
+ `Date` itself.
1084
+
1085
+ Deliberately not here: file sizes, AI model names, loan repayment frequencies,
1086
+ pager arithmetic and the per-line GST recompute for bill approvals. Each has one
1087
+ consumer and is a domain vocabulary rather than a shared value class; the GST
1088
+ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
1089
+ too — it rides on `date-fns` in the one app that has it, and a display formatter
1090
+ is not worth making that a dependency of every consumer.
1091
+
739
1092
  ## Verifying a change
740
1093
 
741
1094
  ```bash
@@ -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}
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ import { Button, type ButtonProps } from '../button/index.js';
3
+
4
+ let { ref = $bindable(null), ...restProps }: ButtonProps = $props();
5
+ </script>
6
+
7
+ <Button bind:ref type="submit" {...restProps} />
@@ -0,0 +1,4 @@
1
+ import { type ButtonProps } from '../button/index.js';
2
+ declare const FormButton: import("svelte").Component<ButtonProps, {}, "ref">;
3
+ type FormButton = ReturnType<typeof FormButton>;
4
+ export default FormButton;
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import * as FormPrimitive from 'formsnap';
3
+ import { cn } from '../../../utils.js';
4
+ import type { WithoutChild } from '../../../utils.js';
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ class: className,
9
+ ...restProps
10
+ }: WithoutChild<FormPrimitive.DescriptionProps> = $props();
11
+ </script>
12
+
13
+ <FormPrimitive.Description
14
+ bind:ref
15
+ data-slot="form-description"
16
+ class={cn('text-muted-foreground text-sm', className)}
17
+ {...restProps}
18
+ />
@@ -0,0 +1,4 @@
1
+ import * as FormPrimitive from 'formsnap';
2
+ declare const FormDescription: import("svelte").Component<Omit<FormPrimitive.DescriptionProps, "child">, {}, "ref">;
3
+ type FormDescription = ReturnType<typeof FormDescription>;
4
+ export default FormDescription;
@@ -0,0 +1,25 @@
1
+ <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
2
+ import * as FormPrimitive from 'formsnap';
3
+ import type { FormPathLeaves } from 'sveltekit-superforms';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ import { cn } from '../../../utils.js';
6
+ import type { WithElementRef, WithoutChildren } from '../../../utils.js';
7
+
8
+ let {
9
+ ref = $bindable(null),
10
+ class: className,
11
+ form,
12
+ name,
13
+ children: childrenProp,
14
+ ...restProps
15
+ }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> &
16
+ FormPrimitive.ElementFieldProps<T, U> = $props();
17
+ </script>
18
+
19
+ <FormPrimitive.ElementField {form} {name}>
20
+ {#snippet children({ constraints, errors, tainted, value })}
21
+ <div bind:this={ref} class={cn('space-y-2', className)} {...restProps}>
22
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
23
+ </div>
24
+ {/snippet}
25
+ </FormPrimitive.ElementField>