@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.
- package/README.md +367 -14
- package/dist/components/ui/button/button.svelte +13 -8
- package/dist/components/ui/checkbox/checkbox.svelte +23 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
- 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/page-header/page-header.svelte +70 -33
- package/dist/components/ui/page-header/page-header.svelte.d.ts +4 -0
- package/dist/components/ui/switch/index.d.ts +2 -2
- package/dist/components/ui/switch/index.js +1 -1
- package/dist/components/ui/switch/switch.svelte +38 -5
- package/dist/components/ui/switch/switch.svelte.d.ts +7 -1
- package/dist/components/ui/tabs/tabs-trigger.svelte +1 -1
- package/dist/format.d.ts +222 -0
- package/dist/format.js +422 -0
- package/dist/styles.css +310 -15
- 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 |
|
|
361
387
|
|
|
362
|
-
`
|
|
363
|
-
|
|
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
|
|
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
|
|
@@ -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,
|
|
716
|
-
|
|
717
|
-
|
|
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-
|
|
24
|
-
xs: "h-
|
|
25
|
-
sm: "h-
|
|
26
|
-
lg: 'h-
|
|
27
|
-
icon: 'size-
|
|
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-
|
|
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-
|
|
32
|
-
'icon-lg': 'size-
|
|
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-
|
|
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-
|
|
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,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>
|