@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.
Files changed (57) hide show
  1. package/README.md +180 -30
  2. package/dist/components/ui/app-shell/app-nav.svelte +12 -12
  3. package/dist/components/ui/app-shell/app-shell.svelte +14 -12
  4. package/dist/components/ui/app-shell/app-shell.svelte.d.ts +14 -6
  5. package/dist/components/ui/app-shell/types.d.ts +20 -3
  6. package/dist/components/ui/app-shell/types.js +29 -5
  7. package/dist/components/ui/collection-detail/collection-detail.svelte +169 -0
  8. package/dist/components/ui/collection-detail/collection-detail.svelte.d.ts +47 -0
  9. package/dist/components/ui/collection-detail/index.d.ts +3 -0
  10. package/dist/components/ui/collection-detail/index.js +2 -0
  11. package/dist/components/ui/document-detail/document-detail.svelte +184 -0
  12. package/dist/components/ui/document-detail/document-detail.svelte.d.ts +34 -0
  13. package/dist/components/ui/document-detail/index.d.ts +3 -0
  14. package/dist/components/ui/document-detail/index.js +2 -0
  15. package/dist/components/ui/library-browse/document-table.svelte +101 -0
  16. package/dist/components/ui/library-browse/document-table.svelte.d.ts +10 -0
  17. package/dist/components/ui/library-browse/facet-rail.svelte +62 -0
  18. package/dist/components/ui/library-browse/facet-rail.svelte.d.ts +8 -0
  19. package/dist/components/ui/library-browse/index.d.ts +3 -0
  20. package/dist/components/ui/library-browse/index.js +2 -0
  21. package/dist/components/ui/library-browse/library-browse.svelte +235 -0
  22. package/dist/components/ui/library-browse/library-browse.svelte.d.ts +44 -0
  23. package/dist/components/ui/library-browse/types.d.ts +101 -0
  24. package/dist/components/ui/library-browse/types.js +1 -0
  25. package/dist/components/ui/schema-form/context.d.ts +31 -0
  26. package/dist/components/ui/schema-form/context.js +10 -0
  27. package/dist/components/ui/schema-form/data.d.ts +42 -0
  28. package/dist/components/ui/schema-form/data.js +97 -0
  29. package/dist/components/ui/schema-form/dispatch.d.ts +35 -0
  30. package/dist/components/ui/schema-form/dispatch.js +139 -0
  31. package/dist/components/ui/schema-form/index.d.ts +5 -0
  32. package/dist/components/ui/schema-form/index.js +5 -0
  33. package/dist/components/ui/schema-form/schema-form-control.svelte +146 -0
  34. package/dist/components/ui/schema-form/schema-form-control.svelte.d.ts +7 -0
  35. package/dist/components/ui/schema-form/schema-form-element.svelte +105 -0
  36. package/dist/components/ui/schema-form/schema-form-element.svelte.d.ts +7 -0
  37. package/dist/components/ui/schema-form/schema-form-widget.svelte +169 -0
  38. package/dist/components/ui/schema-form/schema-form-widget.svelte.d.ts +18 -0
  39. package/dist/components/ui/schema-form/schema-form.svelte +153 -0
  40. package/dist/components/ui/schema-form/schema-form.svelte.d.ts +4 -0
  41. package/dist/components/ui/schema-form/types.d.ts +61 -0
  42. package/dist/components/ui/schema-form/types.js +21 -0
  43. package/dist/components/ui/schema-form/widgets/radio-field.svelte +48 -0
  44. package/dist/components/ui/schema-form/widgets/radio-field.svelte.d.ts +15 -0
  45. package/dist/components/ui/schema-form/widgets/slider-field.svelte +51 -0
  46. package/dist/components/ui/schema-form/widgets/slider-field.svelte.d.ts +13 -0
  47. package/dist/components/ui/schema-form/widgets/tags-field.svelte +98 -0
  48. package/dist/components/ui/schema-form/widgets/tags-field.svelte.d.ts +12 -0
  49. package/dist/components/ui/schema-form/widgets/unknown-field.svelte +123 -0
  50. package/dist/components/ui/schema-form/widgets/unknown-field.svelte.d.ts +20 -0
  51. package/dist/components/ui/search-results/index.d.ts +3 -0
  52. package/dist/components/ui/search-results/index.js +2 -0
  53. package/dist/components/ui/search-results/search-results.svelte +183 -0
  54. package/dist/components/ui/search-results/search-results.svelte.d.ts +40 -0
  55. package/package.json +2 -1
  56. package/registry/component-map.json +52 -3
  57. 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** (20). Primitives are not what makes an app look like an
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
- | `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. |
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`. The other way out
180
- (modules along the top bar, rail for the current module) was rejected on mobile:
181
- it leaves two navigation surfaces that both need collapsing and both want the
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, and the `sidebar` snippet
217
- stays the right home for a column that is not `NavItem`-shaped at all: a document
218
- tree, a table of contents, a filter panel.
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 (the `sidebar` slot's own column) keeps the
264
- page's ink, so inverting the rail does not drag a secondary nav with it.
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 so an app with a SECOND,
7
- * route-scoped navigation column (a per-module sidebar) uses the same
8
- * affordance rather than hand-building a near-copy.
9
- * That was the concrete shape of the drift in the estate: one app's inner
10
- * sidebar had reimplemented the active indicator, the group separators and
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
- * An item carrying `children` discloses them in place, beneath itself. That
14
- * is the same drift arriving from the other direction: an app whose sections
15
- * have their own navigation had nowhere to put it inside the rail, so it put
16
- * its modules here and its per-section pages in AppShell's `sidebar` snippet,
17
- * and shipped two left-hand columns. See `types.ts` on `children` for why the
18
- * nested rail won over modules-on-the-top-bar, and why the depth cap is one.
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, a
60
- * secondary column. Those are snippets, so this package imports no app store,
61
- * no app route and no app brand — the coupling that made the best shell in
62
- * the estate unliftable in the first place.
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, a
59
- * secondary column. Those are snippets, so this package imports no app store,
60
- * no app route and no app brand — the coupling that made the best shell in
61
- * the estate unliftable in the first place.
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 the `sidebar` snippet — two
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 palette.
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 palette.
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
- return nav.flatMap((entry) => {
48
- const items = isNavGroup(entry) ? entry.items : [entry];
49
- return items.flatMap((item) => [item, ...navChildren(item)]);
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) {