@poodle64/ui 2026.8.9 → 2026.8.11

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 CHANGED
@@ -147,7 +147,6 @@ That is the whole minimum. Everything below is optional.
147
147
  | `identity` | The signed-in surface. Rendered once, at the end of the top bar. |
148
148
  | `context`, `actions` | Leading and trailing top-bar slots: a store/tenant switcher, app-level action buttons. |
149
149
  | `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
150
  | `onSearch`, `searchLabel`, `searchShortcut` | Provide `onSearch` to render the search affordance at all. |
152
151
  | `themeToggle`, `onToggleTheme` | Defaults to `mode-watcher`. Set `themeToggle={false}` when the app puts theming inside its own user menu. |
153
152
  | `measure` | How wide the page body may get, from a named scale. Defaults to `full` (no cap). |
@@ -176,10 +175,15 @@ it did before the field existed.
176
175
 
177
176
  It exists because the alternative was two left-hand columns: an app whose
178
177
  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.
178
+ modules in `nav` and the current section's pages in a `sidebar` slot. The other
179
+ way out (modules along the top bar, rail for the current module) was rejected on
180
+ mobile: it leaves two navigation surfaces that both need collapsing and both want
181
+ the same hamburger. One nested tree collapses to one drawer.
182
+
183
+ Since **2026.8.11 it is the only way in**: the `sidebar` slot is gone. What it
184
+ produced was the shell shape differing per app, and in one app per MODULE — two
185
+ of its sections rendered their pages beside the rail and the rest rendered them
186
+ inside it. Operator ruling, 21/08/2026: no app supports an additional sidebar.
183
187
 
184
188
  The behaviour, and why:
185
189
 
@@ -213,9 +217,11 @@ deep, so a section whose own navigation is itself grouped under sub-headings, or
213
217
  whose rows disclose a third level, does not lift into the rail whole. The rail is
214
218
  not the place to fix that. At 15.5rem a third level leaves roughly 128px for the
215
219
  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.
220
+ level on its own page, where there is width for it. A column that is not
221
+ `NavItem`-shaped at all a document tree, a table of contents, a filter panel —
222
+ belongs in the page too, as a sibling of the article it serves, and not in the
223
+ shell: it is part of that page's own reading surface, it wants that page's
224
+ breakpoints, and no other route should be paying rail width for it.
219
225
 
220
226
  `NavItem` / `NavGroup` are exported so an app types its own config against them.
221
227
  They carry **no notion of who may see an item**: two surveyed apps gate
@@ -260,8 +266,8 @@ palette:
260
266
  }
261
267
  ```
262
268
 
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.
269
+ `AppNav` used outside the chrome (a navigation list inside a page) keeps the
270
+ page's ink, so inverting the rail does not drag it along.
265
271
 
266
272
  ### The content measure
267
273
 
@@ -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) {
@@ -95,6 +95,15 @@
95
95
  {/if}
96
96
  </div>
97
97
  {#if actions}
98
- <div class="flex flex-wrap items-center justify-end gap-2">{@render actions()}</div>
98
+ <!--
99
+ `min-w-0` is load-bearing, not tidying. This row is a flex ITEM of the
100
+ header, so it inherits `min-width: auto` — a floor at its own min-content
101
+ width, which its `flex-wrap` cannot get below because wrapping shrinks a
102
+ flex CONTAINER, never its item floor. Measured 2026-08-20 at 360px: an
103
+ actions row rendered 408px inside a 328px parent and pushed 64px of
104
+ sideways scroll into the shell content region, on every consumer using
105
+ PageHeader with actions.
106
+ -->
107
+ <div class="flex min-w-0 flex-wrap items-center justify-end gap-2">{@render actions()}</div>
99
108
  {/if}
100
109
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.8.9",
3
+ "version": "2026.8.11",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.8.9",
4
+ "version": "2026.8.11",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 49,
@@ -1,7 +1,7 @@
1
1
  # @poodle64/ui — situation → component map
2
2
 
3
3
  <!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
4
- Generated from `@poodle64/ui@2026.8.9`. 49 components, 12 situations.
4
+ Generated from `@poodle64/ui@2026.8.11`. 49 components, 12 situations.
5
5
 
6
6
  **Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
7
7