@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.
- package/README.md +305 -12
- package/dist/components/ui/form/form-button.svelte +7 -0
- package/dist/components/ui/form/form-button.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-description.svelte +18 -0
- package/dist/components/ui/form/form-description.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-element-field.svelte +25 -0
- package/dist/components/ui/form/form-element-field.svelte.d.ts +28 -0
- package/dist/components/ui/form/form-field-errors.svelte +31 -0
- package/dist/components/ui/form/form-field-errors.svelte.d.ts +8 -0
- package/dist/components/ui/form/form-field.svelte +25 -0
- package/dist/components/ui/form/form-field.svelte.d.ts +28 -0
- package/dist/components/ui/form/form-fieldset.svelte +16 -0
- package/dist/components/ui/form/form-fieldset.svelte.d.ts +27 -0
- package/dist/components/ui/form/form-label.svelte +25 -0
- package/dist/components/ui/form/form-label.svelte.d.ts +4 -0
- package/dist/components/ui/form/form-legend.svelte +17 -0
- package/dist/components/ui/form/form-legend.svelte.d.ts +4 -0
- package/dist/components/ui/form/index.d.ts +11 -0
- package/dist/components/ui/form/index.js +13 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/library-browse/document-table.svelte +13 -1
- package/dist/components/ui/page-header/page-header.svelte +70 -33
- package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
- package/dist/format.d.ts +222 -0
- package/dist/format.js +422 -0
- package/dist/styles.css +179 -5
- package/package.json +16 -2
- package/registry/component-map.json +74 -4
- 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`
|
|
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}
|
|
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
|
|
360
|
-
| `none` | nothing at all |
|
|
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
|
|
363
|
-
|
|
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
|
|
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.
|
|
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,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,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;
|