@poodle64/ui 2026.8.10 → 2026.8.12
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 +180 -30
- package/dist/components/ui/app-shell/app-nav.svelte +12 -12
- package/dist/components/ui/app-shell/app-shell.svelte +14 -12
- package/dist/components/ui/app-shell/app-shell.svelte.d.ts +14 -6
- package/dist/components/ui/app-shell/types.d.ts +20 -3
- package/dist/components/ui/app-shell/types.js +29 -5
- package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
- package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
- package/dist/components/ui/collection-detail/index.d.ts +3 -0
- package/dist/components/ui/collection-detail/index.js +2 -0
- package/dist/components/ui/document-detail/document-detail.svelte +184 -0
- package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
- package/dist/components/ui/document-detail/index.d.ts +3 -0
- package/dist/components/ui/document-detail/index.js +2 -0
- package/dist/components/ui/library-browse/document-table.svelte +101 -0
- package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
- package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
- package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
- package/dist/components/ui/library-browse/index.d.ts +3 -0
- package/dist/components/ui/library-browse/index.js +2 -0
- package/dist/components/ui/library-browse/library-browse.svelte +235 -0
- package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
- package/dist/components/ui/library-browse/types.d.ts +101 -0
- package/dist/components/ui/library-browse/types.js +1 -0
- package/dist/components/ui/schema-form/context.d.ts +31 -0
- package/dist/components/ui/schema-form/context.js +10 -0
- package/dist/components/ui/schema-form/data.d.ts +42 -0
- package/dist/components/ui/schema-form/data.js +97 -0
- package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
- package/dist/components/ui/schema-form/dispatch.js +139 -0
- package/dist/components/ui/schema-form/index.d.ts +5 -0
- package/dist/components/ui/schema-form/index.js +5 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
- package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
- package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
- package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
- package/dist/components/ui/schema-form/schema-form.svelte +153 -0
- package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
- package/dist/components/ui/schema-form/types.d.ts +61 -0
- package/dist/components/ui/schema-form/types.js +21 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
- package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
- package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
- package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
- package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
- package/dist/components/ui/search-results/index.d.ts +3 -0
- package/dist/components/ui/search-results/index.js +2 -0
- package/dist/components/ui/search-results/search-results.svelte +183 -0
- package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
- package/package.json +2 -1
- package/registry/component-map.json +52 -3
- package/registry/component-map.md +20 -1
package/README.md
CHANGED
|
@@ -74,30 +74,35 @@ the one-owner-per-key note below). It becomes a candidate the day two apps want
|
|
|
74
74
|
the same chart palette; until then a shared chart component would be a shared
|
|
75
75
|
disagreement.
|
|
76
76
|
|
|
77
|
-
**Composed components** (
|
|
77
|
+
**Composed components** (24). Primitives are not what makes an app look like an
|
|
78
78
|
app — the page chrome is. These are the cross-cutting surfaces every route
|
|
79
79
|
composes from, so a household app gets its layout language from the package
|
|
80
80
|
rather than rebuilding it:
|
|
81
81
|
|
|
82
|
-
| Import | What it is
|
|
83
|
-
| ----------------------------------------------- |
|
|
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.
|
|
85
|
-
| `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding.
|
|
86
|
-
| `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions.
|
|
87
|
-
| `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select.
|
|
88
|
-
| `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive.
|
|
89
|
-
| `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically.
|
|
90
|
-
| `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself).
|
|
91
|
-
| `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet.
|
|
92
|
-
| `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`.
|
|
93
|
-
| `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn).
|
|
94
|
-
| `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance.
|
|
95
|
-
| `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart.
|
|
96
|
-
| `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it.
|
|
97
|
-
| `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one.
|
|
98
|
-
| `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children.
|
|
99
|
-
| `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks.
|
|
100
|
-
| `
|
|
82
|
+
| Import | What it is |
|
|
83
|
+
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
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. |
|
|
85
|
+
| `panel` | The generic titled card: optional icon, subtitle and trailing actions over a body that can opt out of padding. |
|
|
86
|
+
| `detail-panel` | The entity-detail surface: header with icon/eyebrow/title/`StatusBadge`/close, scrollable body, footer of actions. |
|
|
87
|
+
| `context-column` | The persistent right-hand column: a standing `StatList` plus an optional detail that flows in on select. |
|
|
88
|
+
| `app-dialog` | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (`xs`…`xl`), and an `onOpenChange` for the dismissals the caller did not drive. |
|
|
89
|
+
| `dialog-section` | One section of a dialogue body; adjacent sections are divided automatically. |
|
|
90
|
+
| `stat-card` | A single metric that earns its space (label, value, unit, sub, status dot, and `valueTone` to colour the figure itself). |
|
|
91
|
+
| `stat-list` | A route's low-context integers as a label→value list. Zero-aware: `muted` keeps a healthy zero quiet. |
|
|
92
|
+
| `arc-gauge` | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a `stat-card`. |
|
|
93
|
+
| `bar-row` | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
|
|
94
|
+
| `scorecard` | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
|
|
95
|
+
| `sparkline` | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
|
|
96
|
+
| `status` / `status-badge` | The fixed five-state vocabulary (`success \| warning \| error \| info \| neutral`) and the one state chip, with `pulse` for a state still in motion and `class` for placement. `status-badge` alone also accepts `'primary'`, a brand-emphasis extension outside the shared vocabulary — `stat-card`, `stat-list` and `data-table-toolbar` never see it. |
|
|
97
|
+
| `empty-state` / `error-state` / `loading-state` | The shared blank, error and loading surfaces. Never hand-roll one. |
|
|
98
|
+
| `info-tip` | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
|
|
99
|
+
| `data-table-toolbar` | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
|
|
100
|
+
| `schema-form` | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see [Server-described forms](#server-described-forms) below. |
|
|
101
|
+
| `data-table-tanstack` | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
|
|
102
|
+
| `library-browse` | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (`LibraryDocument[]`, `LibraryFacet[]`). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is `schema-form` because its shape changes (#30). |
|
|
103
|
+
| `collection-detail` | One collection's surface: identity (`detail-panel`), an at-a-glance `stat-list`, and the documents it holds, with `actions` and `children` slots for the app-specific rest. |
|
|
104
|
+
| `document-detail` | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via `collectionHref`. |
|
|
105
|
+
| `search-results` | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
|
|
101
106
|
|
|
102
107
|
**The application shell** (`app-shell`, `command-palette`). Page chrome is not
|
|
103
108
|
what makes an app feel like an app either — the shell is. Five household
|
|
@@ -147,7 +152,6 @@ That is the whole minimum. Everything below is optional.
|
|
|
147
152
|
| `identity` | The signed-in surface. Rendered once, at the end of the top bar. |
|
|
148
153
|
| `context`, `actions` | Leading and trailing top-bar slots: a store/tenant switcher, app-level action buttons. |
|
|
149
154
|
| `banner` | Full-width region under the bar: reconnect notices, trial warnings. |
|
|
150
|
-
| `sidebar` | A secondary, route-scoped column between the nav and the page body. |
|
|
151
155
|
| `onSearch`, `searchLabel`, `searchShortcut` | Provide `onSearch` to render the search affordance at all. |
|
|
152
156
|
| `themeToggle`, `onToggleTheme` | Defaults to `mode-watcher`. Set `themeToggle={false}` when the app puts theming inside its own user menu. |
|
|
153
157
|
| `measure` | How wide the page body may get, from a named scale. Defaults to `full` (no cap). |
|
|
@@ -176,10 +180,15 @@ it did before the field existed.
|
|
|
176
180
|
|
|
177
181
|
It exists because the alternative was two left-hand columns: an app whose
|
|
178
182
|
sections have inner navigation had nowhere to put it in the rail, so it put
|
|
179
|
-
modules in `nav` and the current section's pages in `sidebar
|
|
180
|
-
(modules along the top bar, rail for the current module) was rejected on
|
|
181
|
-
it leaves two navigation surfaces that both need collapsing and both want
|
|
182
|
-
same hamburger. One nested tree collapses to one drawer.
|
|
183
|
+
modules in `nav` and the current section's pages in a `sidebar` slot. The other
|
|
184
|
+
way out (modules along the top bar, rail for the current module) was rejected on
|
|
185
|
+
mobile: it leaves two navigation surfaces that both need collapsing and both want
|
|
186
|
+
the same hamburger. One nested tree collapses to one drawer.
|
|
187
|
+
|
|
188
|
+
Since **2026.8.11 it is the only way in**: the `sidebar` slot is gone. What it
|
|
189
|
+
produced was the shell shape differing per app, and in one app per MODULE — two
|
|
190
|
+
of its sections rendered their pages beside the rail and the rest rendered them
|
|
191
|
+
inside it. Operator ruling, 21/08/2026: no app supports an additional sidebar.
|
|
183
192
|
|
|
184
193
|
The behaviour, and why:
|
|
185
194
|
|
|
@@ -213,9 +222,11 @@ deep, so a section whose own navigation is itself grouped under sub-headings, or
|
|
|
213
222
|
whose rows disclose a third level, does not lift into the rail whole. The rail is
|
|
214
223
|
not the place to fix that. At 15.5rem a third level leaves roughly 128px for the
|
|
215
224
|
label, which is about fifteen characters. A section that deep keeps its deepest
|
|
216
|
-
level on its own page, where there is width for it
|
|
217
|
-
|
|
218
|
-
|
|
225
|
+
level on its own page, where there is width for it. A column that is not
|
|
226
|
+
`NavItem`-shaped at all — a document tree, a table of contents, a filter panel —
|
|
227
|
+
belongs in the page too, as a sibling of the article it serves, and not in the
|
|
228
|
+
shell: it is part of that page's own reading surface, it wants that page's
|
|
229
|
+
breakpoints, and no other route should be paying rail width for it.
|
|
219
230
|
|
|
220
231
|
`NavItem` / `NavGroup` are exported so an app types its own config against them.
|
|
221
232
|
They carry **no notion of who may see an item**: two surveyed apps gate
|
|
@@ -260,8 +271,8 @@ palette:
|
|
|
260
271
|
}
|
|
261
272
|
```
|
|
262
273
|
|
|
263
|
-
`AppNav` used outside the chrome (
|
|
264
|
-
page's ink, so inverting the rail does not drag
|
|
274
|
+
`AppNav` used outside the chrome (a navigation list inside a page) keeps the
|
|
275
|
+
page's ink, so inverting the rail does not drag it along.
|
|
265
276
|
|
|
266
277
|
### The content measure
|
|
267
278
|
|
|
@@ -497,6 +508,145 @@ but the same class on the `<th>` collapses the heading over its neighbour:
|
|
|
497
508
|
{ accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }
|
|
498
509
|
```
|
|
499
510
|
|
|
511
|
+
## Server-described forms
|
|
512
|
+
|
|
513
|
+
`<SchemaForm>` renders a config object whose shape arrives at runtime. It is the
|
|
514
|
+
estate's one mechanism for that, and this README is where its contract lives.
|
|
515
|
+
|
|
516
|
+
Two standard documents go in. A **JSON Schema** says what the value is; a **JSON
|
|
517
|
+
Forms UI Schema** says how it is laid out — a tree of layouts, Controls
|
|
518
|
+
addressed by JSON Pointer `scope`, and declarative `rule: { effect, condition }`
|
|
519
|
+
for conditional visibility. Both are published standards, which is the point:
|
|
520
|
+
the renderer is replaceable without a server changing anything.
|
|
521
|
+
|
|
522
|
+
```svelte
|
|
523
|
+
<script lang="ts">
|
|
524
|
+
import SchemaForm from '@poodle64/ui/schema-form';
|
|
525
|
+
|
|
526
|
+
let { schema, uischema } = $props(); // fetched by the app, not by this package
|
|
527
|
+
let value = $state({});
|
|
528
|
+
</script>
|
|
529
|
+
|
|
530
|
+
<SchemaForm {schema} {uischema} {value} onChange={(next) => (value = next)} />
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
| Prop | Purpose |
|
|
534
|
+
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
535
|
+
| `schema` | The JSON Schema. Scopes resolve against it, `$ref` included, and it drives validation. |
|
|
536
|
+
| `uischema` | The JSON Forms UI Schema. Omit it and one is generated from the schema, the only mode in which no field can be missing from the layout. |
|
|
537
|
+
| `value` | The current value. The component is controlled and never mutates what it is given. |
|
|
538
|
+
| `onChange` | `(next, { path, value })`. `next` is a fresh object with every untouched branch structurally intact. |
|
|
539
|
+
| `disabled` | Disables every control. |
|
|
540
|
+
| `idPrefix` | Prefix for generated element ids, when two forms share a page. |
|
|
541
|
+
|
|
542
|
+
### It knows nothing about any app
|
|
543
|
+
|
|
544
|
+
No HTTP client, no `fetch`, no endpoint string, no service name, no
|
|
545
|
+
app-specific type. Consumers fetch the two documents and map the result
|
|
546
|
+
themselves. That invariant is load-bearing: it is what lets the engine behind
|
|
547
|
+
the schema be swapped later without touching a consumer, and what stopped three
|
|
548
|
+
apps' renderers from being three different components.
|
|
549
|
+
|
|
550
|
+
### An unrecognised control renders LOUDLY
|
|
551
|
+
|
|
552
|
+
**This is the non-negotiable rule.** The three hand-rolled renderers this
|
|
553
|
+
replaces failed silently: a `widget: "dropdown"` hint rendered no input at all,
|
|
554
|
+
three whole top-level config groups never rendered because their names were
|
|
555
|
+
absent from a hardcoded `GROUP_ORDER`, and nested hints were inert. Every one
|
|
556
|
+
of those made a field VANISH, and a form with a field missing looks exactly like
|
|
557
|
+
a form. They shipped that way for months.
|
|
558
|
+
|
|
559
|
+
So nothing here renders as nothing. Eight failure shapes each produce a
|
|
560
|
+
visibly-flagged block carrying `data-schema-form-unknown` and a
|
|
561
|
+
`data-unknown-reason`, which names what could not be done, quotes the pointer so
|
|
562
|
+
the fix is a copy-paste, shows the value that would otherwise have been lost,
|
|
563
|
+
and keeps it editable wherever a text box cannot destroy structure:
|
|
564
|
+
|
|
565
|
+
| `data-unknown-reason` | Raised when |
|
|
566
|
+
| --------------------- | --------------------------------------------------------------------------------- |
|
|
567
|
+
| `unknown-widget` | `options.format` / `options.widget` names a widget this package does not ship. |
|
|
568
|
+
| `unknown-element` | A UI schema element `type` outside the vocabulary below. |
|
|
569
|
+
| `unresolved-scope` | A Control's `scope` resolves to nothing in the JSON Schema. |
|
|
570
|
+
| `missing-scope` | A Control carries no `scope` at all. |
|
|
571
|
+
| `no-options` | A `select` or `radio` over a subschema that declares no `enum` or `oneOf`. |
|
|
572
|
+
| `object-control` | A Control points at an object, which needs a layout rather than one control. |
|
|
573
|
+
| `unsupported-array` | An array whose items are not primitives, so `tags` cannot represent it. |
|
|
574
|
+
| `not-in-layout` | The JSON Schema describes a property no Control anywhere in the layout addresses. |
|
|
575
|
+
|
|
576
|
+
A primitive value stays editable; an object or an array is shown read-only,
|
|
577
|
+
because a text box over structured data is a data-loss affordance rather than a
|
|
578
|
+
fallback. An unrecognised hint is never quietly swapped for the widget it
|
|
579
|
+
probably meant — `dropdown` almost certainly meant `select`, and guessing would
|
|
580
|
+
restore the field while hiding the fact that the two documents disagree.
|
|
581
|
+
|
|
582
|
+
`not-in-layout` is reported once per unaddressed subtree, at the subtree, so a
|
|
583
|
+
forgotten group is one loud entry rather than forty. There is no prop to silence
|
|
584
|
+
any of this. The way to stop a field being flagged is to put a Control for it in
|
|
585
|
+
the UI schema.
|
|
586
|
+
|
|
587
|
+
`src/test/schema-form-loud-unknown.test.ts` pins all eight reasons, and
|
|
588
|
+
`harness/drive.mjs` proves in a real engine that each flag has real size and a
|
|
589
|
+
resolved, visible warning border. "Renders loudly" is a claim about paint, and
|
|
590
|
+
a fallback styled into invisibility would satisfy every jsdom assertion while
|
|
591
|
+
reproducing the original defect exactly.
|
|
592
|
+
|
|
593
|
+
### The widget dispatch table
|
|
594
|
+
|
|
595
|
+
An explicit hint wins; otherwise the widget is derived from the subschema.
|
|
596
|
+
`options.format` is the JSON Forms spelling and `options.widget` is accepted
|
|
597
|
+
alongside it, so a server migrating off a home-grown hint vocabulary does not
|
|
598
|
+
have to change both documents at once. Any hint outside this table is
|
|
599
|
+
`unknown-widget`.
|
|
600
|
+
|
|
601
|
+
| Widget | Chosen when | Rendered by |
|
|
602
|
+
| ---------- | ----------------------------------------------- | --------------------------------- |
|
|
603
|
+
| `text` | `string` (the default) | `input` |
|
|
604
|
+
| `textarea` | `options.multi: true`, or the hint | `textarea` |
|
|
605
|
+
| `password` | `format: "password"`, or the hint | `input[type=password]` |
|
|
606
|
+
| `date` | `format: "date"` | `input[type=date]` |
|
|
607
|
+
| `time` | `format: "time"` | `input[type=time]` |
|
|
608
|
+
| `datetime` | `format: "date-time"` | `input[type=datetime-local]` |
|
|
609
|
+
| `number` | `number` / `integer` | `input[type=number]` |
|
|
610
|
+
| `slider` | `options.slider: true` on a number, or the hint | composed here (native `range`) |
|
|
611
|
+
| `select` | `enum`, or `oneOf: [{ const, title }]` | `select` |
|
|
612
|
+
| `radio` | the hint, on the same closed value sets | composed here (native `radio`) |
|
|
613
|
+
| `switch` | `boolean` (the default) | `switch` |
|
|
614
|
+
| `checkbox` | the hint, on a boolean | `checkbox` |
|
|
615
|
+
| `tags` | `array` of `string` / `number` / `integer` | composed here (`badge` + `input`) |
|
|
616
|
+
|
|
617
|
+
Three of those — `slider`, `radio` and `tags` — had no primitive in this package
|
|
618
|
+
and are composed inside `schema-form/widgets/`. They are deliberately not
|
|
619
|
+
top-level exports: their only caller is the renderer, and a widget promoted to
|
|
620
|
+
the catalogue before a second consumer wants it is a shape nobody agreed to.
|
|
621
|
+
|
|
622
|
+
### The layout vocabulary
|
|
623
|
+
|
|
624
|
+
`VerticalLayout`, `HorizontalLayout`, `Group` (a `Panel`), `Categorization` /
|
|
625
|
+
`Category` (a `Tabs` set), `Control` and `Label`. Anything else is
|
|
626
|
+
`unknown-element`.
|
|
627
|
+
|
|
628
|
+
`rule` is evaluated on every element, not only on Controls, so a rule on a Group
|
|
629
|
+
takes the whole group with it — which is what an author writing one means.
|
|
630
|
+
`SHOW` / `HIDE` decide whether the element renders at all; `ENABLE` / `DISABLE`
|
|
631
|
+
and `options.readonly` disable the control instead.
|
|
632
|
+
|
|
633
|
+
### The engine
|
|
634
|
+
|
|
635
|
+
`@jsonforms/core` is used **headless**: JSON Pointer scope resolution, rule
|
|
636
|
+
evaluation and the Ajv instance, nothing else. The renderers are this package's
|
|
637
|
+
own and dispatch to this package's own widgets. There is no official Svelte
|
|
638
|
+
renderer for JSON Forms and the community one carries no external validation, so
|
|
639
|
+
neither is used.
|
|
640
|
+
|
|
641
|
+
JSON Forms was chosen over RJSF and `@sjsf/form` on one deciding fact: the
|
|
642
|
+
upstream config models carry 36 conditional-visibility rules, and JSON Forms'
|
|
643
|
+
`rule` model maps onto them 1:1, while RJSF has no standard conditional-
|
|
644
|
+
visibility directive. `@jsonforms/core@3.8.0` was verified working headless
|
|
645
|
+
under Svelte 5 in this package before it was adopted, on 21/08/2026 — scope
|
|
646
|
+
resolution through `$ref`, `SHOW`/`HIDE`/`DISABLE` evaluation, and
|
|
647
|
+
`Generate.uiSchema` all exercised live. It is the package's only runtime
|
|
648
|
+
dependency beyond what was already here.
|
|
649
|
+
|
|
500
650
|
## Consuming the package
|
|
501
651
|
|
|
502
652
|
Published to public npm under the `@poodle64` scope, same as `@poodle64/design-tokens`
|
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
* The vertical navigation list.
|
|
4
4
|
*
|
|
5
5
|
* Rendered by AppShell in the rail/drawer (one element, two states — see
|
|
6
|
-
* app-shell.svelte) and exported in its own right
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the collapse behaviour independently of its own top bar.
|
|
6
|
+
* app-shell.svelte), and exported in its own right for a navigation list that
|
|
7
|
+
* belongs in a PAGE — so it is built from the same affordance rather than
|
|
8
|
+
* hand-copied. That was the concrete shape of the drift in the estate: one
|
|
9
|
+
* app's inner nav had reimplemented the active indicator, the group
|
|
10
|
+
* separators and the collapse behaviour independently of its own top bar.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
* is
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* and
|
|
18
|
-
*
|
|
12
|
+
* It is no longer how an app gets a second navigation COLUMN, because there
|
|
13
|
+
* is no longer such a thing: AppShell's `sidebar` slot was removed in
|
|
14
|
+
* 2026.8.11 (operator ruling, 21/08/2026 — no app supports an additional
|
|
15
|
+
* sidebar). An item carrying `children` discloses them in place, beneath
|
|
16
|
+
* itself, and that is where a section's own pages go at every width. See
|
|
17
|
+
* `types.ts` on `children` for why the nested rail won over
|
|
18
|
+
* modules-on-the-top-bar, and why the depth cap is one.
|
|
19
19
|
*/
|
|
20
20
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
21
21
|
import {
|
|
@@ -56,10 +56,20 @@
|
|
|
56
56
|
* remembered. `collapsible` is on for the same reason.
|
|
57
57
|
*
|
|
58
58
|
* Everything else the surveyed apps differed on turned out to be a slot, not
|
|
59
|
-
* a variant: the brand, the identity surface, a context switcher, a banner
|
|
60
|
-
*
|
|
61
|
-
* no app
|
|
62
|
-
*
|
|
59
|
+
* a variant: the brand, the identity surface, a context switcher, a banner.
|
|
60
|
+
* Those are snippets, so this package imports no app store, no app route and
|
|
61
|
+
* no app brand — the coupling that made the best shell in the estate
|
|
62
|
+
* unliftable in the first place.
|
|
63
|
+
*
|
|
64
|
+
* There is NO second navigation column, and there is no slot to make one.
|
|
65
|
+
* `sidebar` was such a slot until 2026.8.11, and what it produced was two
|
|
66
|
+
* apps rendering a module's own pages beside the rail while the other seven
|
|
67
|
+
* rendered them inside it — the shell shape differing per app, and in one app
|
|
68
|
+
* per MODULE, which is the thing this component exists to stop. Operator
|
|
69
|
+
* ruling, 21/08/2026, on seeing one app's two modules disagree: no app
|
|
70
|
+
* supports an additional sidebar; a section's own pages roll out beneath it
|
|
71
|
+
* in the rail. That is `NavItem.children` (see types.ts), which the rail
|
|
72
|
+
* already discloses at every width and folds into the one drawer on a phone.
|
|
63
73
|
*
|
|
64
74
|
* The minimum useful call is two props:
|
|
65
75
|
*
|
|
@@ -96,7 +106,6 @@
|
|
|
96
106
|
context,
|
|
97
107
|
actions,
|
|
98
108
|
identity,
|
|
99
|
-
sidebar,
|
|
100
109
|
onSearch,
|
|
101
110
|
searchLabel = 'Search…',
|
|
102
111
|
searchShortcut = '⌘K',
|
|
@@ -154,8 +163,6 @@
|
|
|
154
163
|
actions?: Snippet;
|
|
155
164
|
/** The signed-in user surface. Rendered once, at the end of the top bar. */
|
|
156
165
|
identity?: Snippet;
|
|
157
|
-
/** A secondary, route-scoped column between the nav and the page body. */
|
|
158
|
-
sidebar?: Snippet;
|
|
159
166
|
/** Provide to render the search affordance. Usually opens a CommandPalette. */
|
|
160
167
|
onSearch?: () => void;
|
|
161
168
|
searchLabel?: string;
|
|
@@ -590,11 +597,6 @@
|
|
|
590
597
|
{#if banner}{@render banner()}{/if}
|
|
591
598
|
|
|
592
599
|
<div class="flex min-h-0 min-w-0 flex-1">
|
|
593
|
-
{#if sidebar}
|
|
594
|
-
<div class="border-border hidden min-h-0 flex-none border-r md:flex">
|
|
595
|
-
{@render sidebar()}
|
|
596
|
-
</div>
|
|
597
|
-
{/if}
|
|
598
600
|
<!--
|
|
599
601
|
The scrolling content region, and the element a consumer's
|
|
600
602
|
"no horizontal overflow" test must measure.
|
|
@@ -55,10 +55,20 @@
|
|
|
55
55
|
* remembered. `collapsible` is on for the same reason.
|
|
56
56
|
*
|
|
57
57
|
* Everything else the surveyed apps differed on turned out to be a slot, not
|
|
58
|
-
* a variant: the brand, the identity surface, a context switcher, a banner
|
|
59
|
-
*
|
|
60
|
-
* no app
|
|
61
|
-
*
|
|
58
|
+
* a variant: the brand, the identity surface, a context switcher, a banner.
|
|
59
|
+
* Those are snippets, so this package imports no app store, no app route and
|
|
60
|
+
* no app brand — the coupling that made the best shell in the estate
|
|
61
|
+
* unliftable in the first place.
|
|
62
|
+
*
|
|
63
|
+
* There is NO second navigation column, and there is no slot to make one.
|
|
64
|
+
* `sidebar` was such a slot until 2026.8.11, and what it produced was two
|
|
65
|
+
* apps rendering a module's own pages beside the rail while the other seven
|
|
66
|
+
* rendered them inside it — the shell shape differing per app, and in one app
|
|
67
|
+
* per MODULE, which is the thing this component exists to stop. Operator
|
|
68
|
+
* ruling, 21/08/2026, on seeing one app's two modules disagree: no app
|
|
69
|
+
* supports an additional sidebar; a section's own pages roll out beneath it
|
|
70
|
+
* in the rail. That is `NavItem.children` (see types.ts), which the rail
|
|
71
|
+
* already discloses at every width and folds into the one drawer on a phone.
|
|
62
72
|
*
|
|
63
73
|
* The minimum useful call is two props:
|
|
64
74
|
*
|
|
@@ -116,8 +126,6 @@ type $$ComponentProps = {
|
|
|
116
126
|
actions?: Snippet;
|
|
117
127
|
/** The signed-in user surface. Rendered once, at the end of the top bar. */
|
|
118
128
|
identity?: Snippet;
|
|
119
|
-
/** A secondary, route-scoped column between the nav and the page body. */
|
|
120
|
-
sidebar?: Snippet;
|
|
121
129
|
/** Provide to render the search affordance. Usually opens a CommandPalette. */
|
|
122
130
|
onSearch?: () => void;
|
|
123
131
|
searchLabel?: string;
|
|
@@ -57,10 +57,12 @@ export interface NavItem {
|
|
|
57
57
|
*
|
|
58
58
|
* Without this, an app whose sections have their own inner navigation has
|
|
59
59
|
* nowhere to put it inside the rail. The observed workaround was modules in
|
|
60
|
-
* `nav` and the current module's pages in
|
|
60
|
+
* `nav` and the current module's pages in AppShell's `sidebar` snippet — two
|
|
61
61
|
* left-hand columns on a desktop, and, the reason this shape won over moving
|
|
62
62
|
* modules to a top bar, two surfaces on a phone that both want the same
|
|
63
|
-
* hamburger. One nested tree collapses to one drawer.
|
|
63
|
+
* hamburger. One nested tree collapses to one drawer. That slot is gone as
|
|
64
|
+
* of 2026.8.11 and this is the only way in, so a section's navigation is in
|
|
65
|
+
* the same place in every app and on every route.
|
|
64
66
|
*
|
|
65
67
|
* Named `children`, not `items`, for a mechanical reason as well as a
|
|
66
68
|
* readable one: `isNavGroup` narrows on `'items' in entry`, so an item
|
|
@@ -105,11 +107,26 @@ export declare function toGroups(nav: NavSource | undefined): NavGroup[];
|
|
|
105
107
|
export declare function navChildren(item: NavItem): readonly NavChildItem[];
|
|
106
108
|
/**
|
|
107
109
|
* Every item in source order, ignoring grouping — a parent immediately followed
|
|
108
|
-
* by its own children. Used by the command
|
|
110
|
+
* by its own children, each destination appearing ONCE. Used by the command
|
|
111
|
+
* palette.
|
|
109
112
|
*
|
|
110
113
|
* Children are included because they are destinations like any other, and the
|
|
111
114
|
* palette is the fastest route to a page three levels into a section. An item
|
|
112
115
|
* with no children flattens exactly as it always did.
|
|
116
|
+
*
|
|
117
|
+
* The de-duplication is not tidiness. A section that discloses its own pages
|
|
118
|
+
* names its landing page twice by nature — the parent row goes there, and the
|
|
119
|
+
* section's first child row is that same page under its own name ("Property",
|
|
120
|
+
* then "Dashboard"). Both rows are wanted in the rail, where they render in
|
|
121
|
+
* different `{#each}` blocks; flattened they collide, and every consumer keys
|
|
122
|
+
* this list by `href`, so Svelte throws `each_key_duplicate` and the throw
|
|
123
|
+
* takes the palette's whole content with it. Measured in a consumer: ⌘K opened
|
|
124
|
+
* an empty sheet on every module route until the app hand-filtered its own nav
|
|
125
|
+
* before passing it. An app should not have to know that.
|
|
126
|
+
*
|
|
127
|
+
* First occurrence wins, so the surviving entry is the parent's — the row
|
|
128
|
+
* carrying the section's own name, which is what someone typing into a palette
|
|
129
|
+
* is looking for.
|
|
113
130
|
*/
|
|
114
131
|
export declare function toItems(nav: NavSource | undefined): NavItem[];
|
|
115
132
|
/**
|
|
@@ -35,19 +35,43 @@ export function navChildren(item) {
|
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Every item in source order, ignoring grouping — a parent immediately followed
|
|
38
|
-
* by its own children. Used by the command
|
|
38
|
+
* by its own children, each destination appearing ONCE. Used by the command
|
|
39
|
+
* palette.
|
|
39
40
|
*
|
|
40
41
|
* Children are included because they are destinations like any other, and the
|
|
41
42
|
* palette is the fastest route to a page three levels into a section. An item
|
|
42
43
|
* with no children flattens exactly as it always did.
|
|
44
|
+
*
|
|
45
|
+
* The de-duplication is not tidiness. A section that discloses its own pages
|
|
46
|
+
* names its landing page twice by nature — the parent row goes there, and the
|
|
47
|
+
* section's first child row is that same page under its own name ("Property",
|
|
48
|
+
* then "Dashboard"). Both rows are wanted in the rail, where they render in
|
|
49
|
+
* different `{#each}` blocks; flattened they collide, and every consumer keys
|
|
50
|
+
* this list by `href`, so Svelte throws `each_key_duplicate` and the throw
|
|
51
|
+
* takes the palette's whole content with it. Measured in a consumer: ⌘K opened
|
|
52
|
+
* an empty sheet on every module route until the app hand-filtered its own nav
|
|
53
|
+
* before passing it. An app should not have to know that.
|
|
54
|
+
*
|
|
55
|
+
* First occurrence wins, so the surviving entry is the parent's — the row
|
|
56
|
+
* carrying the section's own name, which is what someone typing into a palette
|
|
57
|
+
* is looking for.
|
|
43
58
|
*/
|
|
44
59
|
export function toItems(nav) {
|
|
45
60
|
if (!nav)
|
|
46
61
|
return [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const items = [];
|
|
64
|
+
for (const entry of nav) {
|
|
65
|
+
for (const item of isNavGroup(entry) ? entry.items : [entry]) {
|
|
66
|
+
for (const candidate of [item, ...navChildren(item)]) {
|
|
67
|
+
if (seen.has(candidate.href))
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(candidate.href);
|
|
70
|
+
items.push(candidate);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
51
75
|
}
|
|
52
76
|
/** Prefix-match `path` against `prefix`, without running past a path segment. */
|
|
53
77
|
function matchesPrefix(path, prefix) {
|