@poodle64/ui 2026.9.19 → 2026.9.20

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
@@ -402,6 +402,64 @@ all three serve any route:
402
402
  truncates. Carry one or the other. A page writes a run of `Panel`s and no
403
403
  wrapper: the pane stacks them on the package's section rhythm itself.
404
404
 
405
+ ### A working list beside a page's own content
406
+
407
+ AppShell has no second-column slot, on purpose: a module's own **pages**
408
+ belong in the rail (`NavItem.children`), never a hand-rolled column beside it.
409
+ A module's **data** (a queue, an inbox, a working list of things waiting on
410
+ the user) is a different situation, not navigation, so it belongs in the page
411
+ beside the content it serves, exactly as the settings destination does.
412
+ `ListShell` is that shape: a foldable list, in the rail's own chrome, that
413
+ vanishes entirely when closed and hands the freed width back to the page.
414
+
415
+ ```svelte
416
+ <script lang="ts">
417
+ import AppShell from '@poodle64/ui/app-shell';
418
+ import ListShell from '@poodle64/ui/list-shell';
419
+
420
+ // Bind it to whatever already persists a user preference for this module:
421
+ // a module-scoped `.svelte.ts` store backed by localStorage, or the app's
422
+ // own settings. ListShell holds no storage of its own.
423
+ let listOpen = $state(true);
424
+ </script>
425
+
426
+ <!-- padded={false}: the list has to reach the content area's edge, flush
427
+ against the rail, or it reads as a floating card. -->
428
+ <AppShell {nav} currentPath={page.url.pathname} padded={false}>
429
+ <div class="flex min-h-0 flex-1">
430
+ <ListShell title="Waiting on you" bind:open={listOpen}>
431
+ {#snippet actions()}
432
+ <Button size="icon" variant="outline" aria-label="Add documents"><Plus /></Button>
433
+ {/snippet}
434
+ <!-- tabs, group headings, rows: entirely this module's own -->
435
+ </ListShell>
436
+ <div class="min-w-0 flex-1">
437
+ {#if !listOpen}
438
+ <!-- The reopen control is this module's own markup, at whatever
439
+ position it chooses; ListShell never places it. -->
440
+ <Button onclick={() => (listOpen = true)} aria-expanded={listOpen}>
441
+ Waiting on you <Badge>{waitingCount}</Badge>
442
+ </Button>
443
+ {/if}
444
+ <!-- the module's own content -->
445
+ </div>
446
+ </div>
447
+ </AppShell>
448
+ ```
449
+
450
+ | Prop | Purpose |
451
+ | ---------- | ----------------------------------------------------------------------------- |
452
+ | `title` | The list's heading, in the shell's display face. |
453
+ | `open` | Bindable. Not persisted by this component; bind it to what already persists a preference for this module. |
454
+ | `label` | The landmark's accessible name. Defaults to `title`. |
455
+ | `actions` | Leading header actions beside the title: an "Add" button, a filter. |
456
+
457
+ Width is fixed at 25rem (400px) and not a prop, the same anti-drift discipline
458
+ `SettingsShell` applies to its own 240px: one width, chosen once, rather than
459
+ every module guessing its own. Below `md` this never renders, at either state,
460
+ since a 400px column has nowhere to go beside a rail that is already a drawer
461
+ there; a module's phone view is its own route.
462
+
405
463
  ### The content measure
406
464
 
407
465
  How wide the page body may get, chosen once in the layout from a scale of four:
@@ -0,0 +1,2 @@
1
+ export { default as ListShell } from './list-shell.svelte';
2
+ export { default } from './list-shell.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ListShell } from './list-shell.svelte';
2
+ export { default } from './list-shell.svelte';
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ListShell — a module's working list, folded away and brought back by the
4
+ * app that owns it, beside the content it serves.
5
+ *
6
+ * AppShell carries no second-column slot for this on purpose: `sidebar` was
7
+ * such a slot until 2026.8.11, and what it produced was two apps rendering a
8
+ * module's own PAGES beside the rail while the other seven rendered them
9
+ * inside it — the shell shape differing per app, and in one app per module.
10
+ * Operator ruling, 21/08/2026: no app supports an additional sidebar; a
11
+ * section's own pages roll out beneath it in the rail (`NavItem.children`).
12
+ * That ruling is about NAVIGATION. A working list is not navigation — it is
13
+ * data the module owns (a queue of things waiting on the user), and
14
+ * `20-sveltekit-frontend.md` already answers where that belongs: "a column
15
+ * that is not NavItem-shaped … belongs in the page beside the content it
16
+ * serves." `ContextColumn` is the household's other proof this is a real,
17
+ * sanctioned shape — a page-level flanking `<aside>`, not a shell slot.
18
+ *
19
+ * So this is a companion to AppShell, exactly as `SettingsShell` is: the
20
+ * shell provides ONE chrome (the rail, the bar, the content frame), and any
21
+ * module reaches for this to add a foldable list beside its own content,
22
+ * inside AppShell's content area. Every module gets the same list, not a
23
+ * hand-rolled column per module — which is the actual thing the 21/08/2026
24
+ * ruling exists to prevent, still true one level down.
25
+ *
26
+ * USE IT WITH `padded={false}` on AppShell, exactly as `SettingsShell`
27
+ * requires: the list has to reach the content area's edge — flush against
28
+ * the rail, with no gap — or it reads as a floating card rather than chrome.
29
+ * It paints itself in the rail's OWN chrome tokens (`bg-shell`,
30
+ * `text-shell-foreground`) for the same reason: a list that is chrome reads
31
+ * as chrome, not as a panel someone built inside the page.
32
+ *
33
+ * Width is fixed at 25rem (400px), measured off the operator's approved
34
+ * mockup (radar-hooves/godswood#839, Focus-List.dc.html) rather than picked,
35
+ * and not exposed as a prop — the same anti-drift discipline `SettingsShell`
36
+ * applies to its own 240px: one width, chosen once, or ten modules each
37
+ * guess their own.
38
+ *
39
+ * Open/closed is the CALLER's state (`open`, bindable), the same way
40
+ * AppShell's own `collapsed` is: bind it to persist the choice across
41
+ * reloads (a module-scoped `$state` in a `.svelte.ts` file, synced to
42
+ * `localStorage` — `svelte-basics.md` §shared state — or the app's own
43
+ * settings store). This component holds no storage of its own, on purpose:
44
+ * a module that already persists user preferences server-side should not
45
+ * gain a second, disagreeing mechanism here.
46
+ *
47
+ * When `open` is false this renders NOTHING — no placeholder, no rail — so
48
+ * the content beside it gets the freed width back, exactly like the
49
+ * operator's mockup (Focus-Run.dc.html). Reopening it is deliberately NOT
50
+ * this component's job: the fold toggle only exists while the list is open
51
+ * (inside its own header, where the mockup puts it); the reopen affordance
52
+ * — a "Waiting on you 338" pill, a bare icon, wherever the module wants it —
53
+ * is the CALLER's own markup, driving the same bound `open`. Fixing that
54
+ * button's position here would be exactly the one-shape-per-module drift
55
+ * this file exists to end; the module's own page already earned that
56
+ * decision by owning the content beside this list. A caller's reopen
57
+ * control should carry `aria-expanded={open}` itself, so the two controls
58
+ * (this one, and the caller's) read as one continuous toggle to a screen
59
+ * reader even though only one is ever in the document at a time:
60
+ *
61
+ * <button onclick={() => (open = true)} aria-expanded={open}>
62
+ * Waiting on you <Badge>{waitingCount}</Badge>
63
+ * </button>
64
+ *
65
+ * Below `md` this never renders, at either state: the rail is already a
66
+ * drawer there, and a 400px column has nowhere to go beside it. Godswood's
67
+ * own answer is a dedicated phone route for the same list (Phone.dc.html);
68
+ * that choice is the app's, not this component's — it only gets out of the
69
+ * way so the app's own mobile route can render undisturbed.
70
+ *
71
+ * The header is chrome (title, the fold toggle, and an optional leading
72
+ * `actions` snippet for a module-specific control — an "Add" button in the
73
+ * mockup). Everything below it — tabs, grouped headings, the rows
74
+ * themselves — is the module's own `children`, exactly as `SettingsShell`
75
+ * hands its list body to the caller rather than describing a row shape of
76
+ * its own. A queue is not the only thing this list will ever hold.
77
+ */
78
+ import type { Snippet } from 'svelte';
79
+ import type { HTMLAttributes } from 'svelte/elements';
80
+ import { cn, type WithElementRef } from '../../../utils.js';
81
+ import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
82
+
83
+ let {
84
+ title,
85
+ label = title,
86
+ open = $bindable(true),
87
+ actions,
88
+ children,
89
+ ref = $bindable(null),
90
+ class: className,
91
+ ...restProps
92
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
93
+ /** The list's heading, in the shell's display face. */
94
+ title: string;
95
+ /**
96
+ * The landmark's accessible name. Defaults to `title` — the visible
97
+ * heading already says what this is, so a module only sets `label`
98
+ * separately when the heading text would not stand alone as a name
99
+ * (WCAG 2.4.1), e.g. a heading that leads with a live count.
100
+ */
101
+ label?: string;
102
+ /**
103
+ * Whether the list is open. Bindable, and NOT persisted by this
104
+ * component — bind it to whatever the module already uses to persist a
105
+ * user preference across a reload.
106
+ */
107
+ open?: boolean;
108
+ /** Leading header actions beside the title — an "Add" button, a filter. */
109
+ actions?: Snippet;
110
+ /** The list body: tabs, group headings, rows — entirely the module's own. */
111
+ children: Snippet;
112
+ } = $props();
113
+
114
+ const listId = $props.id();
115
+ </script>
116
+
117
+ {#if open}
118
+ <aside
119
+ bind:this={ref}
120
+ id={listId}
121
+ aria-label={label}
122
+ class={cn(
123
+ 'bg-shell text-shell-foreground border-border hidden w-[25rem] flex-none flex-col border-e md:flex',
124
+ className
125
+ )}
126
+ {...restProps}
127
+ >
128
+ <div class="flex flex-none items-center gap-2 px-5 pt-5 pb-3">
129
+ <h2 class="font-display text-display flex-1 truncate font-semibold tracking-tight">
130
+ {title}
131
+ </h2>
132
+ {#if actions}{@render actions()}{/if}
133
+ <button
134
+ type="button"
135
+ onclick={() => (open = false)}
136
+ aria-expanded="true"
137
+ aria-controls={listId}
138
+ aria-label={`Fold ${label} away`}
139
+ title={`Fold ${label} away`}
140
+ class="text-shell-muted-foreground hover:text-shell-foreground border-border grid size-9 flex-none place-items-center rounded-md border transition-colors"
141
+ >
142
+ <PanelLeftClose class="size-4" />
143
+ </button>
144
+ </div>
145
+ <div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
146
+ {@render children()}
147
+ </div>
148
+ </aside>
149
+ {/if}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ListShell — a module's working list, folded away and brought back by the
3
+ * app that owns it, beside the content it serves.
4
+ *
5
+ * AppShell carries no second-column slot for this on purpose: `sidebar` was
6
+ * such a slot until 2026.8.11, and what it produced was two apps rendering a
7
+ * module's own PAGES beside the rail while the other seven rendered them
8
+ * inside it — the shell shape differing per app, and in one app per module.
9
+ * Operator ruling, 21/08/2026: no app supports an additional sidebar; a
10
+ * section's own pages roll out beneath it in the rail (`NavItem.children`).
11
+ * That ruling is about NAVIGATION. A working list is not navigation — it is
12
+ * data the module owns (a queue of things waiting on the user), and
13
+ * `20-sveltekit-frontend.md` already answers where that belongs: "a column
14
+ * that is not NavItem-shaped … belongs in the page beside the content it
15
+ * serves." `ContextColumn` is the household's other proof this is a real,
16
+ * sanctioned shape — a page-level flanking `<aside>`, not a shell slot.
17
+ *
18
+ * So this is a companion to AppShell, exactly as `SettingsShell` is: the
19
+ * shell provides ONE chrome (the rail, the bar, the content frame), and any
20
+ * module reaches for this to add a foldable list beside its own content,
21
+ * inside AppShell's content area. Every module gets the same list, not a
22
+ * hand-rolled column per module — which is the actual thing the 21/08/2026
23
+ * ruling exists to prevent, still true one level down.
24
+ *
25
+ * USE IT WITH `padded={false}` on AppShell, exactly as `SettingsShell`
26
+ * requires: the list has to reach the content area's edge — flush against
27
+ * the rail, with no gap — or it reads as a floating card rather than chrome.
28
+ * It paints itself in the rail's OWN chrome tokens (`bg-shell`,
29
+ * `text-shell-foreground`) for the same reason: a list that is chrome reads
30
+ * as chrome, not as a panel someone built inside the page.
31
+ *
32
+ * Width is fixed at 25rem (400px), measured off the operator's approved
33
+ * mockup (radar-hooves/godswood#839, Focus-List.dc.html) rather than picked,
34
+ * and not exposed as a prop — the same anti-drift discipline `SettingsShell`
35
+ * applies to its own 240px: one width, chosen once, or ten modules each
36
+ * guess their own.
37
+ *
38
+ * Open/closed is the CALLER's state (`open`, bindable), the same way
39
+ * AppShell's own `collapsed` is: bind it to persist the choice across
40
+ * reloads (a module-scoped `$state` in a `.svelte.ts` file, synced to
41
+ * `localStorage` — `svelte-basics.md` §shared state — or the app's own
42
+ * settings store). This component holds no storage of its own, on purpose:
43
+ * a module that already persists user preferences server-side should not
44
+ * gain a second, disagreeing mechanism here.
45
+ *
46
+ * When `open` is false this renders NOTHING — no placeholder, no rail — so
47
+ * the content beside it gets the freed width back, exactly like the
48
+ * operator's mockup (Focus-Run.dc.html). Reopening it is deliberately NOT
49
+ * this component's job: the fold toggle only exists while the list is open
50
+ * (inside its own header, where the mockup puts it); the reopen affordance
51
+ * — a "Waiting on you 338" pill, a bare icon, wherever the module wants it —
52
+ * is the CALLER's own markup, driving the same bound `open`. Fixing that
53
+ * button's position here would be exactly the one-shape-per-module drift
54
+ * this file exists to end; the module's own page already earned that
55
+ * decision by owning the content beside this list. A caller's reopen
56
+ * control should carry `aria-expanded={open}` itself, so the two controls
57
+ * (this one, and the caller's) read as one continuous toggle to a screen
58
+ * reader even though only one is ever in the document at a time:
59
+ *
60
+ * <button onclick={() => (open = true)} aria-expanded={open}>
61
+ * Waiting on you <Badge>{waitingCount}</Badge>
62
+ * </button>
63
+ *
64
+ * Below `md` this never renders, at either state: the rail is already a
65
+ * drawer there, and a 400px column has nowhere to go beside it. Godswood's
66
+ * own answer is a dedicated phone route for the same list (Phone.dc.html);
67
+ * that choice is the app's, not this component's — it only gets out of the
68
+ * way so the app's own mobile route can render undisturbed.
69
+ *
70
+ * The header is chrome (title, the fold toggle, and an optional leading
71
+ * `actions` snippet for a module-specific control — an "Add" button in the
72
+ * mockup). Everything below it — tabs, grouped headings, the rows
73
+ * themselves — is the module's own `children`, exactly as `SettingsShell`
74
+ * hands its list body to the caller rather than describing a row shape of
75
+ * its own. A queue is not the only thing this list will ever hold.
76
+ */
77
+ import type { Snippet } from 'svelte';
78
+ import type { HTMLAttributes } from 'svelte/elements';
79
+ import { type WithElementRef } from '../../../utils.js';
80
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
81
+ /** The list's heading, in the shell's display face. */
82
+ title: string;
83
+ /**
84
+ * The landmark's accessible name. Defaults to `title` — the visible
85
+ * heading already says what this is, so a module only sets `label`
86
+ * separately when the heading text would not stand alone as a name
87
+ * (WCAG 2.4.1), e.g. a heading that leads with a live count.
88
+ */
89
+ label?: string;
90
+ /**
91
+ * Whether the list is open. Bindable, and NOT persisted by this
92
+ * component — bind it to whatever the module already uses to persist a
93
+ * user preference across a reload.
94
+ */
95
+ open?: boolean;
96
+ /** Leading header actions beside the title — an "Add" button, a filter. */
97
+ actions?: Snippet;
98
+ /** The list body: tabs, group headings, rows — entirely the module's own. */
99
+ children: Snippet;
100
+ };
101
+ declare const ListShell: import("svelte").Component<$$ComponentProps, {}, "open" | "ref">;
102
+ type ListShell = ReturnType<typeof ListShell>;
103
+ export default ListShell;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.19",
3
+ "version": "2026.9.20",
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.19",
4
+ "version": "2026.9.20",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 60,
7
+ "componentCount": 61,
8
8
  "situationCount": 17
9
9
  },
10
10
  "situations": [
@@ -298,7 +298,7 @@
298
298
  {
299
299
  "key": "page-frame",
300
300
  "title": "The frame every route shares",
301
- "description": "The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column.",
301
+ "description": "The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column, a foldable working list beside the page's own content.",
302
302
  "components": [
303
303
  {
304
304
  "name": "AppShell",
@@ -335,6 +335,13 @@
335
335
  "props": "stats, statsTitle?, statsInfo?, detail?, ariaLabel?",
336
336
  "insteadOf": "a hand-built right-hand <aside> with a stat card and a detail pane"
337
337
  },
338
+ {
339
+ "name": "ListShell",
340
+ "dir": "list-shell",
341
+ "import": "@poodle64/ui/list-shell",
342
+ "props": "title, children, label?, open?, actions?",
343
+ "insteadOf": "a module hand-rolling its own foldable second column beside the rail — a queue, an inbox, a working list"
344
+ },
338
345
  {
339
346
  "name": "AppIdentity",
340
347
  "dir": "app-identity",
@@ -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.19`. 60 components, 17 situations.
4
+ Generated from `@poodle64/ui@2026.9.20`. 61 components, 17 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
 
@@ -129,7 +129,7 @@ _One page at a time, fitted to its pane both ways so an A4 statement and a tall
129
129
 
130
130
  ## The frame every route shares
131
131
 
132
- _The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column._
132
+ _The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column, a foldable working list beside the page's own content._
133
133
 
134
134
  | Component | Import | Key props | Reach for it instead of |
135
135
  | --- | --- | --- | --- |
@@ -138,6 +138,7 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
138
138
  | `Segmented` | `@poodle64/ui/segmented` | `value, options, label, size?` | a hand-built row of aria-pressed buttons for a small fixed choice |
139
139
  | `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) |
140
140
  | `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
141
+ | `ListShell` | `@poodle64/ui/list-shell` | `title, children, label?, open?, actions?` | a module hand-rolling its own foldable second column beside the rail — a queue, an inbox, a working list |
141
142
  | `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |
142
143
 
143
144
  ## The settings destination