@poodle64/ui 2026.8.14 → 2026.8.17

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 (29) hide show
  1. package/README.md +305 -12
  2. package/dist/components/ui/form/form-button.svelte +7 -0
  3. package/dist/components/ui/form/form-button.svelte.d.ts +4 -0
  4. package/dist/components/ui/form/form-description.svelte +18 -0
  5. package/dist/components/ui/form/form-description.svelte.d.ts +4 -0
  6. package/dist/components/ui/form/form-element-field.svelte +25 -0
  7. package/dist/components/ui/form/form-element-field.svelte.d.ts +28 -0
  8. package/dist/components/ui/form/form-field-errors.svelte +31 -0
  9. package/dist/components/ui/form/form-field-errors.svelte.d.ts +8 -0
  10. package/dist/components/ui/form/form-field.svelte +25 -0
  11. package/dist/components/ui/form/form-field.svelte.d.ts +28 -0
  12. package/dist/components/ui/form/form-fieldset.svelte +16 -0
  13. package/dist/components/ui/form/form-fieldset.svelte.d.ts +27 -0
  14. package/dist/components/ui/form/form-label.svelte +25 -0
  15. package/dist/components/ui/form/form-label.svelte.d.ts +4 -0
  16. package/dist/components/ui/form/form-legend.svelte +17 -0
  17. package/dist/components/ui/form/form-legend.svelte.d.ts +4 -0
  18. package/dist/components/ui/form/index.d.ts +11 -0
  19. package/dist/components/ui/form/index.js +13 -0
  20. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  21. package/dist/components/ui/library-browse/document-table.svelte +13 -1
  22. package/dist/components/ui/page-header/page-header.svelte +70 -33
  23. package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
  24. package/dist/format.d.ts +222 -0
  25. package/dist/format.js +422 -0
  26. package/dist/styles.css +179 -5
  27. package/package.json +16 -2
  28. package/registry/component-map.json +74 -4
  29. 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 |
387
+
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.
361
394
 
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:
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
@@ -736,6 +964,67 @@ differ on. An app using them registers them itself.
736
964
  source scan misses them. Without it the components render unstyled (no build
737
965
  error, no lint hit; the classes just never reach the compiled CSS).
738
966
 
967
+ ## Australian value formatters
968
+
969
+ ```ts
970
+ import { formatCurrency, formatDate, formatPercentage } from '@poodle64/ui/format';
971
+ ```
972
+
973
+ Money, dates and times, percentages and plain numbers, in `en-AU` / AUD /
974
+ `Australia/Brisbane`. No dependencies and no DOM — it is `Intl` and arithmetic,
975
+ so it runs in a load function as happily as in a component.
976
+
977
+ | Function | What it does |
978
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
979
+ | `formatCurrency(dollars, opts)` | `$1,234.56`. `decimals` (default 2), `currency` (default AUD), `fallback`. |
980
+ | `formatCurrencyFromCents(cents, opts)` | The same, for money stored as integer cents. |
981
+ | `formatCurrencyString(str, opts)` | Groups a money STRING without ever parsing it to a float — for a figure that reaches a tax return. |
982
+ | `compactCurrency(value, opts)` | `$1.1m` / `$12k`, for a chart axis or a dense tile. |
983
+ | `isNegativeMoney(value)` | The sign, without a parse, for choosing a tone class. |
984
+ | `dollarsToCents(dollars)` | Whole cents. |
985
+ | `formatNumber(value, opts)` | `1,234,567`. |
986
+ | `formatPercentage(value, opts)` | Value is already in percentage POINTS: `4.5` → `4.5%`. |
987
+ | `formatRatioAsPercentage(value, opts)` | Value is a 0–1 RATIO: `0.045` → `4.5%`. |
988
+ | `formatDate(value, opts)` | `19 Dec 2024`, `19 December 2024` (`format: 'long'`) or `19/12/2024` (`format: 'numeric'`). |
989
+ | `formatDateTime(value, opts)` | The same plus a 24-hour time: `19 Dec 2024, 14:05`. |
990
+ | `parseApiDate(iso)` | Reads an offset-less timestamp as UTC — the naive-UTC backend trap below. |
991
+
992
+ Four decisions worth knowing before a migration, because each was settled
993
+ against an app that had decided it the other way:
994
+
995
+ - **A missing value renders `N/A`**, and every formatter takes `fallback` to say
996
+ otherwise. `-` is deliberately not the default: beside a money column it reads
997
+ as a minus sign.
998
+ - **Money keeps its cents by default.** Dropping them is a loss of fidelity the
999
+ caller asks for (`{ decimals: 0 }`), not the default that rounds $1,234.56 up
1000
+ to $1,235.
1001
+ - **A negative carries its sign outside the symbol** — `-$1,234.56`, never
1002
+ `$-1,234.56`.
1003
+ - **`formatPercentage` and `formatRatioAsPercentage` are named apart on
1004
+ purpose.** The two apps had settled on `formatPercent` meaning opposite
1005
+ things; reusing either spelling for both would make a 100x error a
1006
+ one-character mistake.
1007
+
1008
+ Timestamps are read in `Australia/Brisbane` unless a `timeZone` is passed. The
1009
+ household's books are kept in AEST, so a laptop in another zone should not
1010
+ renumber them — and it makes the output deterministic under CI. A date-only
1011
+ value (`YYYY-MM-DD`) gets no zone conversion at all: a date is not an instant,
1012
+ and converting one is how a booking dated the 1st shows as the 31st.
1013
+
1014
+ `parseApiDate` exists because a backend that stores naive UTC
1015
+ (`datetime.now(UTC).replace(tzinfo=None)`) serialises it with no offset, and
1016
+ JavaScript reads that as LOCAL time. In Brisbane that lands every stored moment
1017
+ ten hours early, so anything after 2pm UTC shows the wrong DAY. `formatDate` and
1018
+ `formatDateTime` already go through it; call it directly when you need the
1019
+ `Date` itself.
1020
+
1021
+ Deliberately not here: file sizes, AI model names, loan repayment frequencies,
1022
+ pager arithmetic and the per-line GST recompute for bill approvals. Each has one
1023
+ consumer and is a domain vocabulary rather than a shared value class; the GST
1024
+ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
1025
+ too — it rides on `date-fns` in the one app that has it, and a display formatter
1026
+ is not worth making that a dependency of every consumer.
1027
+
739
1028
  ## Verifying a change
740
1029
 
741
1030
  ```bash
@@ -751,7 +1040,11 @@ node harness/additivity.mjs ui-v2026.8.3
751
1040
  Tailwind chain, exactly as a consuming app wires it — a class that generates no
752
1041
  rule, a colour that resolves to nothing, a registered theme key nothing reads, a
753
1042
  variant whose selector cannot match the DOM, and a sizing utility that has
754
- silently changed meaning all fail there rather than shipping.
1043
+ silently changed meaning all fail there rather than shipping. It also sweeps the
1044
+ source for the one craft defect none of those can see — a content container that
1045
+ draws a border and declares no surface, so it computes transparent and the page
1046
+ shows through it (`src/test/bordered-surface.test.ts`, which carries its own
1047
+ limits at the foot of the file).
755
1048
 
756
1049
  `pnpm run test:browser` is the leg nothing else can stand in for: jsdom applies
757
1050
  no stylesheet and returns unresolved `var(…)` literals, so it passes just as
@@ -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>
@@ -0,0 +1,28 @@
1
+ import * as FormPrimitive from 'formsnap';
2
+ import type { FormPathLeaves } from 'sveltekit-superforms';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { WithElementRef } from '../../../utils.js';
5
+ declare function $$render<T extends Record<string, unknown>, U extends FormPathLeaves<T>>(): {
6
+ props: Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, "children"> & FormPrimitive.ElementFieldProps<T, U, any>;
7
+ exports: {};
8
+ bindings: "ref";
9
+ slots: {};
10
+ events: {};
11
+ };
12
+ declare class __sveltets_Render<T extends Record<string, unknown>, U extends FormPathLeaves<T>> {
13
+ props(): ReturnType<typeof $$render<T, U>>['props'];
14
+ events(): ReturnType<typeof $$render<T, U>>['events'];
15
+ slots(): ReturnType<typeof $$render<T, U>>['slots'];
16
+ bindings(): "ref";
17
+ exports(): {};
18
+ }
19
+ interface $$IsomorphicComponent {
20
+ new <T extends Record<string, unknown>, U extends FormPathLeaves<T>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, U>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, U>['props']>, ReturnType<__sveltets_Render<T, U>['events']>, ReturnType<__sveltets_Render<T, U>['slots']>> & {
21
+ $$bindings?: ReturnType<__sveltets_Render<T, U>['bindings']>;
22
+ } & ReturnType<__sveltets_Render<T, U>['exports']>;
23
+ <T extends Record<string, unknown>, U extends FormPathLeaves<T>>(internal: unknown, props: ReturnType<__sveltets_Render<T, U>['props']> & {}): ReturnType<__sveltets_Render<T, U>['exports']>;
24
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
25
+ }
26
+ declare const FormElementField: $$IsomorphicComponent;
27
+ type FormElementField<T extends Record<string, unknown>, U extends FormPathLeaves<T>> = InstanceType<typeof FormElementField<T, U>>;
28
+ export default FormElementField;
@@ -0,0 +1,31 @@
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
+ errorClasses,
10
+ children: childrenProp,
11
+ ...restProps
12
+ }: WithoutChild<FormPrimitive.FieldErrorsProps> & {
13
+ errorClasses?: string | undefined | null;
14
+ } = $props();
15
+ </script>
16
+
17
+ <FormPrimitive.FieldErrors
18
+ bind:ref
19
+ class={cn('text-destructive text-sm font-medium', className)}
20
+ {...restProps}
21
+ >
22
+ {#snippet children({ errors, errorProps })}
23
+ {#if childrenProp}
24
+ {@render childrenProp({ errors, errorProps })}
25
+ {:else}
26
+ {#each errors as error (error)}
27
+ <div {...errorProps} class={cn(errorClasses)}>{error}</div>
28
+ {/each}
29
+ {/if}
30
+ {/snippet}
31
+ </FormPrimitive.FieldErrors>
@@ -0,0 +1,8 @@
1
+ import * as FormPrimitive from 'formsnap';
2
+ import type { WithoutChild } from '../../../utils.js';
3
+ type $$ComponentProps = WithoutChild<FormPrimitive.FieldErrorsProps> & {
4
+ errorClasses?: string | undefined | null;
5
+ };
6
+ declare const FormFieldErrors: import("svelte").Component<$$ComponentProps, {}, "ref">;
7
+ type FormFieldErrors = ReturnType<typeof FormFieldErrors>;
8
+ export default FormFieldErrors;
@@ -0,0 +1,25 @@
1
+ <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
2
+ import * as FormPrimitive from 'formsnap';
3
+ import type { FormPath } from 'sveltekit-superforms';
4
+ import { cn } from '../../../utils.js';
5
+ import type { WithElementRef, WithoutChildren } from '../../../utils.js';
6
+ import type { HTMLAttributes } from 'svelte/elements';
7
+
8
+ let {
9
+ ref = $bindable(null),
10
+ class: className,
11
+ form,
12
+ name,
13
+ children: childrenProp,
14
+ ...restProps
15
+ }: FormPrimitive.FieldProps<T, U> &
16
+ WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
17
+ </script>
18
+
19
+ <FormPrimitive.Field {form} {name}>
20
+ {#snippet children({ constraints, errors, tainted, value })}
21
+ <div bind:this={ref} data-slot="form-item" class={cn('space-y-2', className)} {...restProps}>
22
+ {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
23
+ </div>
24
+ {/snippet}
25
+ </FormPrimitive.Field>
@@ -0,0 +1,28 @@
1
+ import * as FormPrimitive from 'formsnap';
2
+ import type { FormPath } from 'sveltekit-superforms';
3
+ import type { WithElementRef } from '../../../utils.js';
4
+ import type { HTMLAttributes } from 'svelte/elements';
5
+ declare function $$render<T extends Record<string, unknown>, U extends FormPath<T>>(): {
6
+ props: FormPrimitive.FieldProps<T, U, any> & Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, "children">;
7
+ exports: {};
8
+ bindings: "ref";
9
+ slots: {};
10
+ events: {};
11
+ };
12
+ declare class __sveltets_Render<T extends Record<string, unknown>, U extends FormPath<T>> {
13
+ props(): ReturnType<typeof $$render<T, U>>['props'];
14
+ events(): ReturnType<typeof $$render<T, U>>['events'];
15
+ slots(): ReturnType<typeof $$render<T, U>>['slots'];
16
+ bindings(): "ref";
17
+ exports(): {};
18
+ }
19
+ interface $$IsomorphicComponent {
20
+ new <T extends Record<string, unknown>, U extends FormPath<T>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, U>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, U>['props']>, ReturnType<__sveltets_Render<T, U>['events']>, ReturnType<__sveltets_Render<T, U>['slots']>> & {
21
+ $$bindings?: ReturnType<__sveltets_Render<T, U>['bindings']>;
22
+ } & ReturnType<__sveltets_Render<T, U>['exports']>;
23
+ <T extends Record<string, unknown>, U extends FormPath<T>>(internal: unknown, props: ReturnType<__sveltets_Render<T, U>['props']> & {}): ReturnType<__sveltets_Render<T, U>['exports']>;
24
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
25
+ }
26
+ declare const FormField: $$IsomorphicComponent;
27
+ type FormField<T extends Record<string, unknown>, U extends FormPath<T>> = InstanceType<typeof FormField<T, U>>;
28
+ export default FormField;
@@ -0,0 +1,16 @@
1
+ <script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
2
+ import * as FormPrimitive from 'formsnap';
3
+ import type { FormPath } from 'sveltekit-superforms';
4
+ import { cn } from '../../../utils.js';
5
+ import type { WithoutChild } from '../../../utils.js';
6
+
7
+ let {
8
+ ref = $bindable(null),
9
+ class: className,
10
+ form,
11
+ name,
12
+ ...restProps
13
+ }: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
14
+ </script>
15
+
16
+ <FormPrimitive.Fieldset bind:ref {form} {name} class={cn('space-y-2', className)} {...restProps} />
@@ -0,0 +1,27 @@
1
+ import * as FormPrimitive from 'formsnap';
2
+ import type { FormPath } from 'sveltekit-superforms';
3
+ import type { WithoutChild } from '../../../utils.js';
4
+ declare function $$render<T extends Record<string, unknown>, U extends FormPath<T>>(): {
5
+ props: WithoutChild<FormPrimitive.FieldsetProps<T, U>>;
6
+ exports: {};
7
+ bindings: "ref";
8
+ slots: {};
9
+ events: {};
10
+ };
11
+ declare class __sveltets_Render<T extends Record<string, unknown>, U extends FormPath<T>> {
12
+ props(): ReturnType<typeof $$render<T, U>>['props'];
13
+ events(): ReturnType<typeof $$render<T, U>>['events'];
14
+ slots(): ReturnType<typeof $$render<T, U>>['slots'];
15
+ bindings(): "ref";
16
+ exports(): {};
17
+ }
18
+ interface $$IsomorphicComponent {
19
+ new <T extends Record<string, unknown>, U extends FormPath<T>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, U>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, U>['props']>, ReturnType<__sveltets_Render<T, U>['events']>, ReturnType<__sveltets_Render<T, U>['slots']>> & {
20
+ $$bindings?: ReturnType<__sveltets_Render<T, U>['bindings']>;
21
+ } & ReturnType<__sveltets_Render<T, U>['exports']>;
22
+ <T extends Record<string, unknown>, U extends FormPath<T>>(internal: unknown, props: ReturnType<__sveltets_Render<T, U>['props']> & {}): ReturnType<__sveltets_Render<T, U>['exports']>;
23
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
24
+ }
25
+ declare const FormFieldset: $$IsomorphicComponent;
26
+ type FormFieldset<T extends Record<string, unknown>, U extends FormPath<T>> = InstanceType<typeof FormFieldset<T, U>>;
27
+ export default FormFieldset;