@poodle64/ui 2026.9.7 → 2026.9.8

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
@@ -164,6 +164,28 @@ That is the whole minimum. Everything below is optional.
164
164
  | `texture` | The house atmosphere on the content region. Defaults to `grid`; `none` is the opt-out. |
165
165
  | `padded`, `mainClass` | Padding, and extra classes on the scrolling content container. |
166
166
 
167
+ The bar also names where you are — the active nav label, read off `nav` and
168
+ `currentPath` — and carries the page's scope controls. A page registers them
169
+ with `ShellControls`; they render after the location, inline from `xl` and on
170
+ a second row of the bar below that:
171
+
172
+ ```svelte
173
+ <script lang="ts">
174
+ import { ShellControls } from '@poodle64/ui/app-shell';
175
+ import { Segmented } from '@poodle64/ui/segmented';
176
+ let year = $state('FY26');
177
+ </script>
178
+
179
+ <ShellControls>
180
+ <Segmented bind:value={year} options={YEARS} label="Financial year" size="sm" />
181
+ </ShellControls>
182
+ ```
183
+
184
+ A scope control changes the view of the page you are on and never
185
+ navigates; sub-routes are `NavItem.children`. The rail's right edge is a drag
186
+ handle (200–420px, arrows nudge, Home resets, a drag below 170px collapses);
187
+ the width persists in `localStorage` as `ds-shell-rail-width`.
188
+
167
189
  ### Nested navigation
168
190
 
169
191
  A section with its own navigation puts it on the item, as `children`, and the
@@ -1230,17 +1252,6 @@ non-zero and naming the field on any difference.
1230
1252
  1. Change a component; bump `version` in `package.json` (CalVer).
1231
1253
  2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
1232
1254
  3. Commit, tag `ui-v<version>`, push the tag.
1233
- 4. `.github/workflows/publish.yaml` runs on that push and should publish via
1234
- npm OIDC trusted publishing: check it with
1235
- `gh run list --workflow=publish.yaml`. As of 2026.9.4 every run since
1236
- `ui-v2026.9.2` fails at the `npm publish` step with
1237
- `E404 Not Found - PUT .../@poodle64%2fui` (an OIDC/trusted-publisher
1238
- binding issue, since `Build and test package` passes first). Until that
1239
- is diagnosed and fixed, publish manually from `packages/ui/`:
1240
- ```bash
1241
- printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
1242
- signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
1243
- --credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
1244
- rm -f .npmrc
1245
- ```
1255
+ 4. `.github/workflows/publish.yaml` runs on that push and publishes via npm
1256
+ OIDC trusted publishing: `gh run list --workflow=publish.yaml`.
1246
1257
  Confirm with `npm view @poodle64/ui version`.
@@ -89,7 +89,8 @@
89
89
  import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
90
90
  import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
91
91
  import AppNav from './app-nav.svelte';
92
- import { toItems, type NavSource } from './types.js';
92
+ import { activeNavLabel, toItems, type NavSource } from './types.js';
93
+ import { provideShellControls, type ShellControlsSlot } from './controls.js';
93
94
  import { cn } from '../../../utils.js';
94
95
 
95
96
  let {
@@ -214,6 +215,92 @@
214
215
 
215
216
  const hasNav = $derived(toItems(nav).length > 0);
216
217
 
218
+ // Where the user is, in words: the rail is icons when collapsed and a closed
219
+ // drawer on a phone, so the bar names the section.
220
+ const location = $derived(activeNavLabel(nav, currentPath));
221
+
222
+ // The page's scope controls, registered by `<ShellControls>` from the route.
223
+ const controls: ShellControlsSlot = $state({ content: null });
224
+ provideShellControls(controls);
225
+
226
+ // Rail width is a personal preference: localStorage, one key across apps.
227
+ // A drag below RAIL_SNAP collapses; the width survives and returns on expand.
228
+ const RAIL_KEY = 'ds-shell-rail-width';
229
+ const RAIL_MIN = 200;
230
+ const RAIL_MAX = 420;
231
+ const RAIL_SNAP = 170;
232
+ const RAIL_STEP = 16;
233
+ let railWidth = $state<number | null>(null);
234
+ let resizing = $state(false);
235
+ $effect(() => {
236
+ try {
237
+ const stored = Number(localStorage.getItem(RAIL_KEY));
238
+ if (stored >= RAIL_MIN && stored <= RAIL_MAX) railWidth = stored;
239
+ } catch {
240
+ /* storage unavailable: the default width stands */
241
+ }
242
+ });
243
+ function clampRail(next: number) {
244
+ return Math.min(RAIL_MAX, Math.max(RAIL_MIN, Math.round(next)));
245
+ }
246
+ function setRailWidth(next: number) {
247
+ railWidth = clampRail(next);
248
+ try {
249
+ localStorage.setItem(RAIL_KEY, String(railWidth));
250
+ } catch {
251
+ /* storage unavailable: the width lasts the session */
252
+ }
253
+ }
254
+ function startResize(event: PointerEvent) {
255
+ const handle = event.currentTarget as HTMLElement;
256
+ const rail = handle.parentElement as HTMLElement;
257
+ const startX = event.clientX;
258
+ const startWidth = rail.getBoundingClientRect().width;
259
+ const before = railWidth;
260
+ resizing = true;
261
+ handle.setPointerCapture(event.pointerId);
262
+ const move = (e: PointerEvent) => {
263
+ const next = startWidth + (e.clientX - startX);
264
+ if (next < RAIL_SNAP) {
265
+ // A collapse keeps the width it had; only a resize stores one.
266
+ railWidth = before;
267
+ collapsed = true;
268
+ stop();
269
+ } else {
270
+ railWidth = clampRail(next);
271
+ }
272
+ };
273
+ const stop = () => {
274
+ resizing = false;
275
+ if (!collapsed && railWidth !== null) setRailWidth(railWidth);
276
+ handle.removeEventListener('pointermove', move);
277
+ handle.removeEventListener('pointerup', stop);
278
+ handle.removeEventListener('pointercancel', stop);
279
+ if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
280
+ };
281
+ handle.addEventListener('pointermove', move);
282
+ handle.addEventListener('pointerup', stop);
283
+ handle.addEventListener('pointercancel', stop);
284
+ }
285
+ function resizeByKey(event: KeyboardEvent) {
286
+ const current = railWidth ?? RAIL_MIN + (RAIL_MAX - RAIL_MIN) / 4;
287
+ if (event.key === 'ArrowLeft') setRailWidth(current - RAIL_STEP);
288
+ else if (event.key === 'ArrowRight') setRailWidth(current + RAIL_STEP);
289
+ else if (event.key === 'Home') resetRailWidth();
290
+ else if (event.key === 'End') setRailWidth(RAIL_MAX);
291
+ else return;
292
+ event.preventDefault();
293
+ }
294
+ /** Back to the package default (Home, or a double-click on the handle). */
295
+ function resetRailWidth() {
296
+ railWidth = null;
297
+ try {
298
+ localStorage.removeItem(RAIL_KEY);
299
+ } catch {
300
+ /* storage unavailable */
301
+ }
302
+ }
303
+
217
304
  // The measure is resolved ONCE, and the class and the attribute both read
218
305
  // that one answer. Two independently-worded conditionals looked symmetric
219
306
  // and were not: Svelte omits an attribute whose value is `null`, while a
@@ -478,8 +565,10 @@
478
565
  -->
479
566
  <aside
480
567
  id={navRegionId}
481
- class="ds-shell-rail bg-shell text-shell-foreground border-border flex-none flex-col border-r"
568
+ class="ds-shell-rail bg-shell text-shell-foreground border-border relative flex-none flex-col border-r"
569
+ style={railWidth ? `--ds-shell-rail-width: ${railWidth}px` : undefined}
482
570
  data-collapsed={railCollapsed ? 'true' : undefined}
571
+ data-resizing={resizing ? 'true' : undefined}
483
572
  data-drawer={mobileNavOpen ? 'true' : undefined}
484
573
  data-testid={mobileNavOpen ? 'ds-shell-drawer' : undefined}
485
574
  role={mobileNavOpen ? 'dialog' : undefined}
@@ -540,12 +629,33 @@
540
629
  label={navLabel}
541
630
  onNavigate={() => (mobileNavOpen = false)}
542
631
  />
632
+ {#if collapsible && !railCollapsed && !mobileNavOpen}
633
+ <!-- The rail's right edge: drag to resize, drag past the snap to
634
+ collapse, arrows to nudge, Home to reset, double-click to reset.
635
+ A focusable separator is the ARIA window-splitter pattern. -->
636
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
637
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
638
+ <div
639
+ role="separator"
640
+ aria-orientation="vertical"
641
+ aria-label="Resize sidebar"
642
+ aria-valuenow={railWidth ?? undefined}
643
+ aria-valuemin={RAIL_MIN}
644
+ aria-valuemax={RAIL_MAX}
645
+ tabindex="0"
646
+ class="ds-shell-rail-handle"
647
+ onpointerdown={startResize}
648
+ onkeydown={resizeByKey}
649
+ ondblclick={resetRailWidth}
650
+ data-testid="ds-rail-resize"
651
+ ></div>
652
+ {/if}
543
653
  </aside>
544
654
  {/if}
545
655
 
546
656
  <div class="flex min-h-0 min-w-0 flex-1 flex-col">
547
657
  <header
548
- class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5"
658
+ class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none flex-wrap items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5 xl:flex-nowrap"
549
659
  >
550
660
  {#if hasNav}
551
661
  <!-- `aria-controls` names the region this toggle opens. It is present
@@ -589,6 +699,28 @@
589
699
  leading slot that never had to. -->
590
700
  {#if context}<div class="min-w-0">{@render context()}</div>{/if}
591
701
 
702
+ <!-- Where you are, then the page's scope controls. Inline with the
703
+ rest of the bar from xl; below that, controls take a second row
704
+ of the bar, and a bare label shows from md and not on a phone. -->
705
+ {#if location || controls.content}
706
+ <div
707
+ class={cn(
708
+ 'flex min-w-0 items-center gap-2 sm:gap-3',
709
+ controls.content
710
+ ? 'order-last basis-full pb-2 xl:order-none xl:basis-auto xl:pb-0'
711
+ : 'hidden md:flex'
712
+ )}
713
+ data-testid="ds-shell-location"
714
+ >
715
+ {#if location}
716
+ <span class="text-shell-muted-foreground max-w-48 flex-none truncate text-sm"
717
+ >{location}</span
718
+ >
719
+ {/if}
720
+ {#if controls.content}{@render controls.content()}{/if}
721
+ </div>
722
+ {/if}
723
+
592
724
  {#if onSearch && searchPlacement === 'leading'}
593
725
  {@render searchButton(
594
726
  'h-9 w-9 flex-none justify-center px-0 sm:h-auto sm:w-[clamp(200px,32vw,560px)] sm:flex-1 sm:justify-start sm:gap-2 sm:px-3 sm:py-1.5'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The page's scope controls in the shell's top bar: what changes the view of
3
+ * the page you are on (an entity, a financial year, chart/table) and never
4
+ * navigates. Sub-routes are the rail's (`NavItem.children`), never here.
5
+ *
6
+ * Controls belong to a route and the shell is composed once above every
7
+ * route, so the shell holds a mutable slot in context; `<ShellControls>` in a
8
+ * page registers its children on mount and withdraws them on unmount. One
9
+ * registration at a time; a later one replaces an earlier one.
10
+ */
11
+ import { type Snippet } from 'svelte';
12
+ export interface ShellControlsSlot {
13
+ content: Snippet | null;
14
+ }
15
+ export declare function provideShellControls(slot: ShellControlsSlot): void;
16
+ /** Undefined outside a shell: `ShellControls` then renders nothing. */
17
+ export declare function getShellControls(): ShellControlsSlot | undefined;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The page's scope controls in the shell's top bar: what changes the view of
3
+ * the page you are on (an entity, a financial year, chart/table) and never
4
+ * navigates. Sub-routes are the rail's (`NavItem.children`), never here.
5
+ *
6
+ * Controls belong to a route and the shell is composed once above every
7
+ * route, so the shell holds a mutable slot in context; `<ShellControls>` in a
8
+ * page registers its children on mount and withdraws them on unmount. One
9
+ * registration at a time; a later one replaces an earlier one.
10
+ */
11
+ import { getContext, setContext } from 'svelte';
12
+ const KEY = Symbol('ds-shell-controls');
13
+ export function provideShellControls(slot) {
14
+ setContext(KEY, slot);
15
+ }
16
+ /** Undefined outside a shell: `ShellControls` then renders nothing. */
17
+ export function getShellControls() {
18
+ return getContext(KEY);
19
+ }
@@ -1,6 +1,7 @@
1
1
  export { default as AppShell } from './app-shell.svelte';
2
2
  export { default as AppNav } from './app-nav.svelte';
3
+ export { default as ShellControls } from './shell-controls.svelte';
3
4
  export { default } from './app-shell.svelte';
4
5
  export { SHELL_MEASURES, type ShellMeasure } from './measure.js';
5
6
  export { SHELL_TEXTURES, type ShellTexture } from './texture.js';
6
- export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
7
+ export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
@@ -1,6 +1,7 @@
1
1
  export { default as AppShell } from './app-shell.svelte';
2
2
  export { default as AppNav } from './app-nav.svelte';
3
+ export { default as ShellControls } from './shell-controls.svelte';
3
4
  export { default } from './app-shell.svelte';
4
5
  export { SHELL_MEASURES } from './measure.js';
5
6
  export { SHELL_TEXTURES } from './texture.js';
6
- export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
7
+ export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Registers this page's controls in the shell's top bar. Renders nothing
4
+ * where it stands.
5
+ *
6
+ * <ShellControls>
7
+ * <Segmented bind:value={fy} options={years} label="Financial year" />
8
+ * </ShellControls>
9
+ */
10
+ import type { Snippet } from 'svelte';
11
+ import { getShellControls } from './controls.js';
12
+
13
+ let { children }: { children: Snippet } = $props();
14
+
15
+ const slot = getShellControls();
16
+
17
+ $effect(() => {
18
+ if (!slot) return;
19
+ slot.content = children;
20
+ return () => {
21
+ if (slot.content === children) slot.content = null;
22
+ };
23
+ });
24
+ </script>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Registers this page's controls in the shell's top bar. Renders nothing
3
+ * where it stands.
4
+ *
5
+ * <ShellControls>
6
+ * <Segmented bind:value={fy} options={years} label="Financial year" />
7
+ * </ShellControls>
8
+ */
9
+ import type { Snippet } from 'svelte';
10
+ type $$ComponentProps = {
11
+ children: Snippet;
12
+ };
13
+ declare const ShellControls: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type ShellControls = ReturnType<typeof ShellControls>;
15
+ export default ShellControls;
@@ -149,3 +149,9 @@ export declare function isNavItemActive(item: NavItem, currentPath: string | und
149
149
  * when the children live at their own top-level routes.
150
150
  */
151
151
  export declare function hasActiveNavChild(item: NavItem, currentPath: string | undefined): boolean;
152
+ /**
153
+ * The label of where the user is: the active child's where one matches, else
154
+ * the active item's. Undefined when nothing matches, so an unlisted route
155
+ * renders no label rather than a wrong one.
156
+ */
157
+ export declare function activeNavLabel(nav: NavSource | undefined, currentPath: string | undefined): string | undefined;
@@ -110,3 +110,20 @@ export function hasActiveNavChild(item, currentPath) {
110
110
  return false;
111
111
  return navChildren(item).some((child) => isNavItemActive(child, currentPath));
112
112
  }
113
+ /**
114
+ * The label of where the user is: the active child's where one matches, else
115
+ * the active item's. Undefined when nothing matches, so an unlisted route
116
+ * renders no label rather than a wrong one.
117
+ */
118
+ export function activeNavLabel(nav, currentPath) {
119
+ if (!currentPath)
120
+ return undefined;
121
+ for (const item of toItems(nav)) {
122
+ const child = navChildren(item).find((c) => isNavItemActive(c, currentPath));
123
+ if (child)
124
+ return child.label;
125
+ if (isNavItemActive(item, currentPath))
126
+ return item.label;
127
+ }
128
+ return undefined;
129
+ }
@@ -0,0 +1,2 @@
1
+ import Root, { type SegmentedOption } from './segmented.svelte';
2
+ export { Root, Root as Segmented, type SegmentedOption };
@@ -0,0 +1,4 @@
1
+ import Root, {} from './segmented.svelte';
2
+ export { Root,
3
+ //
4
+ Root as Segmented };
@@ -0,0 +1,62 @@
1
+ <script lang="ts" module>
2
+ export interface SegmentedOption<T extends string = string> {
3
+ value: T;
4
+ label: string;
5
+ }
6
+ </script>
7
+
8
+ <script lang="ts" generics="T extends string">
9
+ /**
10
+ * One value from a small fixed set, every option visible: the scope toggle
11
+ * in the shell's top bar. Past about five options, use Select. A scope
12
+ * always has one answer, so the value cannot be cleared.
13
+ */
14
+ import { ToggleGroup } from 'bits-ui';
15
+ import { cn } from '../../../utils.js';
16
+
17
+ let {
18
+ value = $bindable(),
19
+ options,
20
+ label,
21
+ size = 'default',
22
+ class: className
23
+ }: {
24
+ value: T;
25
+ options: readonly SegmentedOption<T>[];
26
+ /** The control's accessible name: what the set is a choice of. */
27
+ label: string;
28
+ /** `sm` is the top-bar height; `default` sits in a page's own control row. */
29
+ size?: 'default' | 'sm';
30
+ class?: string;
31
+ } = $props();
32
+
33
+ const ITEM = {
34
+ default: 'h-8 px-3 text-sm',
35
+ sm: 'h-6 px-2 text-xs'
36
+ } as const;
37
+ </script>
38
+
39
+ <ToggleGroup.Root
40
+ type="single"
41
+ bind:value={() => value, (v) => { if (v) value = v as T; }}
42
+ aria-label={label}
43
+ class={cn(
44
+ 'border-border bg-surface-1 inline-flex flex-none items-center gap-0.5 rounded-md border p-0.5',
45
+ className
46
+ )}
47
+ data-slot="segmented"
48
+ >
49
+ {#each options as option (option.value)}
50
+ <ToggleGroup.Item
51
+ value={option.value}
52
+ class={cn(
53
+ 'text-muted-foreground hover:text-foreground rounded-sm font-medium whitespace-nowrap transition-colors',
54
+ 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
55
+ 'data-[state=on]:bg-surface-3 data-[state=on]:text-foreground data-[state=on]:font-semibold',
56
+ ITEM[size]
57
+ )}
58
+ >
59
+ {option.label}
60
+ </ToggleGroup.Item>
61
+ {/each}
62
+ </ToggleGroup.Root>
@@ -0,0 +1,36 @@
1
+ export interface SegmentedOption<T extends string = string> {
2
+ value: T;
3
+ label: string;
4
+ }
5
+ declare function $$render<T extends string>(): {
6
+ props: {
7
+ value: T;
8
+ options: readonly SegmentedOption<T>[];
9
+ /** The control's accessible name: what the set is a choice of. */
10
+ label: string;
11
+ /** `sm` is the top-bar height; `default` sits in a page's own control row. */
12
+ size?: "default" | "sm";
13
+ class?: string;
14
+ };
15
+ exports: {};
16
+ bindings: "value";
17
+ slots: {};
18
+ events: {};
19
+ };
20
+ declare class __sveltets_Render<T extends string> {
21
+ props(): ReturnType<typeof $$render<T>>['props'];
22
+ events(): ReturnType<typeof $$render<T>>['events'];
23
+ slots(): ReturnType<typeof $$render<T>>['slots'];
24
+ bindings(): "value";
25
+ exports(): {};
26
+ }
27
+ interface $$IsomorphicComponent {
28
+ new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
29
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
30
+ } & ReturnType<__sveltets_Render<T>['exports']>;
31
+ <T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
32
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
33
+ }
34
+ declare const Segmented: $$IsomorphicComponent;
35
+ type Segmented<T extends string> = InstanceType<typeof Segmented<T>>;
36
+ export default Segmented;
package/dist/styles.css CHANGED
@@ -952,6 +952,36 @@
952
952
  width: var(--ds-shell-rail-width-collapsed);
953
953
  }
954
954
 
955
+ /* Width follows the pointer during a drag; the transition is for the
956
+ collapse, not for every pixel of a resize. */
957
+ .ds-shell-rail[data-resizing='true'] {
958
+ transition: none;
959
+ }
960
+
961
+ /* The resize handle: a 6px grab strip astride the rail's right edge, md+
962
+ only, showing the border colour under the pointer and keyboard focus. */
963
+ .ds-shell-rail-handle {
964
+ display: none;
965
+ }
966
+ @media (min-width: 48rem) {
967
+ .ds-shell-rail-handle {
968
+ display: block;
969
+ position: absolute;
970
+ inset-block: 0;
971
+ right: -3px;
972
+ width: 6px;
973
+ cursor: col-resize;
974
+ z-index: 1;
975
+ touch-action: none;
976
+ }
977
+ .ds-shell-rail-handle:hover,
978
+ .ds-shell-rail-handle:focus-visible {
979
+ background: var(--ds-color-primary);
980
+ opacity: 0.4;
981
+ outline: none;
982
+ }
983
+ }
984
+
955
985
  /* The content measure: the shell's content box is ONE element in four
956
986
  states, the same shape as the rail above it.
957
987
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.7",
3
+ "version": "2026.9.8",
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,10 +1,10 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.7",
4
+ "version": "2026.9.8",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 56,
7
+ "componentCount": 57,
8
8
  "situationCount": 15
9
9
  },
10
10
  "situations": [
@@ -286,6 +286,20 @@
286
286
  "props": "nav, currentPath, brandTitle?, actions?, context?, children",
287
287
  "insteadOf": "a bespoke nav rail + header layout per app"
288
288
  },
289
+ {
290
+ "name": "ShellControls",
291
+ "dir": "app-shell",
292
+ "import": "@poodle64/ui/app-shell",
293
+ "props": "children",
294
+ "insteadOf": "a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it"
295
+ },
296
+ {
297
+ "name": "Segmented",
298
+ "dir": "segmented",
299
+ "import": "@poodle64/ui/segmented",
300
+ "props": "value, options, label, size?",
301
+ "insteadOf": "a hand-built row of aria-pressed buttons for a small fixed choice"
302
+ },
289
303
  {
290
304
  "name": "PageHeader",
291
305
  "dir": "page-header",
@@ -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.9.7`. 56 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.8`. 57 components, 15 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
 
@@ -125,6 +125,8 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
125
125
  | Component | Import | Key props | Reach for it instead of |
126
126
  | --- | --- | --- | --- |
127
127
  | `AppShell` | `@poodle64/ui/app-shell` | `nav, currentPath, brandTitle?, actions?, context?, children` | a bespoke nav rail + header layout per app |
128
+ | `ShellControls` | `@poodle64/ui/app-shell` | `children` | a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it |
129
+ | `Segmented` | `@poodle64/ui/segmented` | `value, options, label, size?` | a hand-built row of aria-pressed buttons for a small fixed choice |
128
130
  | `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
129
131
  | `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
130
132
  | `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |