@poodle64/ui 2026.9.20 → 2026.9.22

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
@@ -42,6 +42,8 @@ src/lib/
42
42
  page-chrome components built on top of them
43
43
  components/feedback/ ReportWidget: the fixed bottom-right "report a problem"
44
44
  trigger and dialogue every app mounts (see below)
45
+ components/alerts/ AlertBell: the top-bar bell over the app factory's alerts
46
+ routes (see below)
45
47
  dist/ generated by `pnpm build` (@sveltejs/package); never edit
46
48
  ```
47
49
 
@@ -404,14 +406,6 @@ wrapper: the pane stacks them on the package's section rhythm itself.
404
406
 
405
407
  ### A working list beside a page's own content
406
408
 
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
409
  ```svelte
416
410
  <script lang="ts">
417
411
  import AppShell from '@poodle64/ui/app-shell';
@@ -454,12 +448,6 @@ vanishes entirely when closed and hands the freed width back to the page.
454
448
  | `label` | The landmark's accessible name. Defaults to `title`. |
455
449
  | `actions` | Leading header actions beside the title: an "Add" button, a filter. |
456
450
 
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
-
463
451
  ### The content measure
464
452
 
465
453
  How wide the page body may get, chosen once in the layout from a scale of four:
@@ -1334,6 +1322,19 @@ routes by situation, so it stands outside the situation→component registry
1334
1322
  for the same reason. `getSessionId()` reading null when telemetry is off is
1335
1323
  ordinary, not an error: the report sends with a null `session_id`.
1336
1324
 
1325
+ ## The alerts bell
1326
+
1327
+ `@poodle64/ui/alerts` (`AlertBell`) is app machinery of the same kind: the app
1328
+ factory's layout mounts it once, in the shell's `actions` slot, for callers
1329
+ holding `admin`. It shows what machines raised through the app's `/mcp` `alerts`
1330
+ tool, from the factory's routes under the `endpoint` prop (default
1331
+ `/api/alerts`): `GET <endpoint>/` lists `{ alerts, total, unread }`, `POST
1332
+ <endpoint>/<id>/read` marks one read, `DELETE <endpoint>/<id>` dismisses it.
1333
+ `src/test/alert-bell.test.ts` asserts each call. It renders nothing while no
1334
+ alert is open, polls once a minute and again as its panel opens, and reports a
1335
+ failed read or dismissal with `toast.error`, so `svelte-sonner` is required
1336
+ here. The app factory's `docs/design/alerts.md` carries the design.
1337
+
1337
1338
  ## The gate: `ds-check-colour-surface`
1338
1339
 
1339
1340
  The package ships the gate that proves its own surface actually resolves in the
@@ -1410,6 +1411,6 @@ non-zero and naming the field on any difference.
1410
1411
  1. Change a component; bump `version` in `package.json` (CalVer).
1411
1412
  2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
1412
1413
  3. Commit, tag `ui-v<version>`, push the tag.
1413
- 4. `.github/workflows/publish.yaml` runs on that push and publishes via npm
1414
- OIDC trusted publishing: `gh run list --workflow=publish.yaml`.
1414
+ 4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes via npm
1415
+ OIDC trusted publishing: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`.
1415
1416
  Confirm with `npm view @poodle64/ui version`.
@@ -0,0 +1,177 @@
1
+ <script lang="ts" module>
2
+ /** One alert as the endpoint lists it: the app factory's `AlertRead`. */
3
+ export type Alert = {
4
+ id: number;
5
+ key: string;
6
+ title: string;
7
+ body: string | null;
8
+ link: string | null;
9
+ raised_at: string;
10
+ read_at: string | null;
11
+ };
12
+ </script>
13
+
14
+ <script lang="ts">
15
+ import Bell from '@lucide/svelte/icons/bell';
16
+ import X from '@lucide/svelte/icons/x';
17
+ import { onMount } from 'svelte';
18
+ import { toast } from 'svelte-sonner';
19
+ import { Badge } from '../ui/badge/index.js';
20
+ import { Button } from '../ui/button/index.js';
21
+ import * as Popover from '../ui/popover/index.js';
22
+ import { cn } from '../../utils';
23
+
24
+ // The alerts bell: what machines told this deployment's operator, in the
25
+ // shell's top-bar `actions` slot. App machinery like ReportWidget, mounted
26
+ // once by the layout and talking to the app factory's own routes: GET
27
+ // `<endpoint>/` lists, POST `<endpoint>/<id>/read` marks read, DELETE
28
+ // `<endpoint>/<id>` dismisses.
29
+ //
30
+ // It renders NOTHING while no alert is open, so an app no producer writes
31
+ // to carries no bell. The layout decides who may see it; the server's gate
32
+ // is the real one.
33
+
34
+ let {
35
+ /** The app's alerts route, defaulting to the one every factory app serves. */
36
+ endpoint = '/api/alerts'
37
+ }: { endpoint?: string } = $props();
38
+
39
+ const POLL_MS = 60_000;
40
+ const TIMEOUT_MS = 30_000;
41
+ const since = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
42
+
43
+ let alerts: Alert[] = $state([]);
44
+ let total = $state(0);
45
+ let unread = $state(0);
46
+ let open = $state(false);
47
+
48
+ function call(path: string, method = 'GET'): Promise<Response | null> {
49
+ return fetch(`${endpoint}/${path}`, { method, signal: AbortSignal.timeout(TIMEOUT_MS) }).catch(
50
+ () => null
51
+ );
52
+ }
53
+
54
+ // A failed poll keeps what the bell last showed and is not worth a toast:
55
+ // the next one, a minute on, tries again.
56
+ async function load() {
57
+ const response = await call('');
58
+ if (!response?.ok) return;
59
+ const data = (await response.json()) as { alerts: Alert[]; total: number; unread: number };
60
+ alerts = data.alerts;
61
+ total = data.total;
62
+ unread = data.unread;
63
+ }
64
+
65
+ async function act(path: string, method: string, failure: string) {
66
+ const response = await call(path, method);
67
+ if (!response?.ok) toast.error(failure);
68
+ await load();
69
+ }
70
+
71
+ function markRead(alert: Alert) {
72
+ if (alert.read_at) return;
73
+ void act(`${alert.id}/read`, 'POST', 'Could not mark the alert read');
74
+ }
75
+
76
+ // Following a link closes the panel; the read is recorded on the way out.
77
+ function follow(alert: Alert) {
78
+ open = false;
79
+ markRead(alert);
80
+ }
81
+
82
+ function age(iso: string): string {
83
+ const minutes = Math.round((new Date(iso).getTime() - Date.now()) / 60_000);
84
+ if (minutes > -60) return since.format(minutes, 'minute');
85
+ if (minutes > -60 * 24) return since.format(Math.round(minutes / 60), 'hour');
86
+ return since.format(Math.round(minutes / (60 * 24)), 'day');
87
+ }
88
+
89
+ onMount(() => {
90
+ void load();
91
+ const timer = setInterval(() => void load(), POLL_MS);
92
+ return () => clearInterval(timer);
93
+ });
94
+ </script>
95
+
96
+ {#if total > 0}
97
+ <Popover.Root bind:open onOpenChange={(opened) => opened && void load()}>
98
+ <Popover.Trigger>
99
+ {#snippet child({ props })}
100
+ <!-- size-9 is the shell's own top-bar control size (its search and
101
+ theme buttons), which size="icon" follows at one density only. -->
102
+ <Button
103
+ {...props}
104
+ variant="outline"
105
+ size="icon"
106
+ class="relative size-9"
107
+ aria-label={unread > 0 ? `Alerts, ${unread} unread` : 'Alerts'}
108
+ data-testid="alert-bell"
109
+ >
110
+ <Bell />
111
+ {#if unread > 0}
112
+ <Badge
113
+ class="absolute -top-1.5 -right-1.5 h-4 min-w-4 px-1"
114
+ aria-hidden="true"
115
+ data-testid="alert-bell-unread"
116
+ >
117
+ {unread > 99 ? '99+' : unread}
118
+ </Badge>
119
+ {/if}
120
+ </Button>
121
+ {/snippet}
122
+ </Popover.Trigger>
123
+
124
+ <Popover.Content align="end" class="w-96 p-0">
125
+ <p class="border-b px-4 py-3 text-sm font-semibold">Alerts</p>
126
+ <ul class="divide-y">
127
+ {#each alerts as alert (alert.id)}
128
+ <li class="flex items-start gap-2 px-4 py-3" data-testid="alert-item">
129
+ <span
130
+ class={cn('mt-1.5 size-2 shrink-0 rounded-full', !alert.read_at && 'bg-primary')}
131
+ aria-hidden="true"
132
+ ></span>
133
+ {#if alert.link}
134
+ <a
135
+ href={alert.link}
136
+ class="min-w-0 flex-1 text-left"
137
+ target={alert.link.startsWith('/') ? undefined : '_blank'}
138
+ rel={alert.link.startsWith('/') ? undefined : 'noreferrer'}
139
+ onclick={() => follow(alert)}
140
+ >
141
+ {@render content(alert)}
142
+ </a>
143
+ {:else}
144
+ <button type="button" class="min-w-0 flex-1 text-left" onclick={() => markRead(alert)}>
145
+ {@render content(alert)}
146
+ </button>
147
+ {/if}
148
+ <Button
149
+ variant="ghost"
150
+ size="icon-xs"
151
+ aria-label="Dismiss"
152
+ onclick={() => void act(String(alert.id), 'DELETE', 'Could not dismiss the alert')}
153
+ data-testid="alert-dismiss"
154
+ >
155
+ <X />
156
+ </Button>
157
+ </li>
158
+ {/each}
159
+ </ul>
160
+ {#if total > alerts.length}
161
+ <p class="text-muted-foreground border-t px-4 py-2 text-xs">
162
+ Showing the newest {alerts.length} of {total}.
163
+ </p>
164
+ {/if}
165
+ </Popover.Content>
166
+ </Popover.Root>
167
+ {/if}
168
+
169
+ {#snippet content(alert: Alert)}
170
+ <span class={cn('block text-sm leading-tight', !alert.read_at && 'font-medium')}>
171
+ {alert.title}
172
+ </span>
173
+ {#if alert.body}
174
+ <span class="text-muted-foreground mt-1 block text-xs whitespace-pre-line">{alert.body}</span>
175
+ {/if}
176
+ <span class="text-muted-foreground mt-1 block text-xs">{age(alert.raised_at)}</span>
177
+ {/snippet}
@@ -0,0 +1,16 @@
1
+ /** One alert as the endpoint lists it: the app factory's `AlertRead`. */
2
+ export type Alert = {
3
+ id: number;
4
+ key: string;
5
+ title: string;
6
+ body: string | null;
7
+ link: string | null;
8
+ raised_at: string;
9
+ read_at: string | null;
10
+ };
11
+ type $$ComponentProps = {
12
+ endpoint?: string;
13
+ };
14
+ declare const AlertBell: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type AlertBell = ReturnType<typeof AlertBell>;
16
+ export default AlertBell;
@@ -0,0 +1,2 @@
1
+ import AlertBell, { type Alert } from './alert-bell.svelte';
2
+ export { AlertBell, AlertBell as default, type Alert };
@@ -0,0 +1,2 @@
1
+ import AlertBell, {} from './alert-bell.svelte';
2
+ export { AlertBell, AlertBell as default };
@@ -9,6 +9,6 @@ type $$ComponentProps = WithoutChildrenOrChild<DialogPrimitive.RootProps> & With
9
9
  showCloseButton?: boolean;
10
10
  class?: string;
11
11
  };
12
- declare const CommandDialog: import("svelte").Component<$$ComponentProps, {}, "open" | "ref" | "value">;
12
+ declare const CommandDialog: import("svelte").Component<$$ComponentProps, {}, "ref" | "value" | "open">;
13
13
  type CommandDialog = ReturnType<typeof CommandDialog>;
14
14
  export default CommandDialog;
@@ -150,98 +150,115 @@
150
150
  class="flex-1 rounded-none border-0"
151
151
  />
152
152
  {:else}
153
- <div class="min-h-0 flex-1 overflow-auto">
154
- <TableRoot class="w-full">
155
- <TableHeader class="bg-card sticky top-0 z-10">
156
- {#each headerGroups as headerGroup (headerGroup.id)}
157
- <TableRow class="border-0 hover:bg-transparent">
158
- {#if selectable}
159
- <TableHead class="border-border w-10 border-b px-3 py-1.5">
160
- <Checkbox
161
- checked={allSelected}
162
- indeterminate={someSelected}
163
- onCheckedChange={toggleAll}
164
- aria-label="Select all rows"
153
+ <!-- containerClass targets table.svelte's own scroll container (`data-slot=
154
+ table-container`), which otherwise carries only `overflow-x-auto` — and
155
+ `overflow-x` other than visible forces `overflow-y` to compute `auto`
156
+ too, making that div a scroll container in its own right even though it
157
+ never actually scrolls (it is always sized to its own content). A
158
+ `position: sticky` header's containing block is its NEAREST ancestor
159
+ scroll container, so with no override here the header (radar-hooves/
160
+ godswood, 30/09/2026) sticks to that inert box and never visibly pins,
161
+ in every engine, not only WebKit — confirmed with this repo's own
162
+ Playwright (chromium and webkit) driving the unmodified component.
163
+ Folding this component's own intended scroll region into that SAME box
164
+ (rather than wrapping a second one around it) makes it the one thing
165
+ that actually scrolls, so sticky has a real ancestor to pin against. -->
166
+ <TableRoot class="w-full" containerClass="min-h-0 flex-1 overflow-y-auto">
167
+ <!-- Sticky lives on each `<th>`, not on `<thead>`: WebKit has a long
168
+ history of not reliably keeping `position: sticky` on a table-section
169
+ box (`<thead>`) pinned, while sticky on the `<th>` cells — which
170
+ participate in table layout as ordinary boxes — sticks correctly. -->
171
+ <TableHeader>
172
+ {#each headerGroups as headerGroup (headerGroup.id)}
173
+ <TableRow class="border-0 hover:bg-transparent">
174
+ {#if selectable}
175
+ <TableHead
176
+ class="border-border bg-card sticky top-0 z-10 w-10 border-b px-3 py-1.5"
177
+ >
178
+ <Checkbox
179
+ checked={allSelected}
180
+ indeterminate={someSelected}
181
+ onCheckedChange={toggleAll}
182
+ aria-label="Select all rows"
183
+ />
184
+ </TableHead>
185
+ {/if}
186
+ {#each headerGroup.headers as header (header.id)}
187
+ {@const meta = header.column.columnDef.meta}
188
+ <TableHead
189
+ class={cn(
190
+ 'text-muted-foreground border-border bg-card sticky top-0 z-10 text-2xs tracking-eyebrow border-b px-3 py-1.5 text-left font-medium whitespace-nowrap uppercase',
191
+ meta?.class,
192
+ meta?.headClass
193
+ )}
194
+ >
195
+ {#if !header.isPlaceholder}
196
+ <FlexRender
197
+ content={header.column.columnDef.header}
198
+ context={header.getContext()}
165
199
  />
166
- </TableHead>
167
- {/if}
168
- {#each headerGroup.headers as header (header.id)}
169
- {@const meta = header.column.columnDef.meta}
170
- <TableHead
171
- class={cn(
172
- 'text-muted-foreground border-border text-2xs tracking-eyebrow border-b px-3 py-1.5 text-left font-medium whitespace-nowrap uppercase',
173
- meta?.class,
174
- meta?.headClass
175
- )}
176
- >
177
- {#if !header.isPlaceholder}
178
- <FlexRender
179
- content={header.column.columnDef.header}
180
- context={header.getContext()}
181
- />
182
- {/if}
183
- </TableHead>
184
- {/each}
185
- </TableRow>
186
- {/each}
187
- </TableHeader>
200
+ {/if}
201
+ </TableHead>
202
+ {/each}
203
+ </TableRow>
204
+ {/each}
205
+ </TableHeader>
188
206
 
189
- <TableBody>
190
- {#each rows as row (row.id)}
191
- {@const originalRow = row.original}
192
- {@const rowId = getRowId(originalRow)}
193
- {@const isSelected = rowId === selectedId}
194
- {@const isChecked = selectedSet.has(rowId)}
195
- {@const extraStyle = getRowStyle ? getRowStyle(originalRow) : ''}
196
- <TableRow
197
- data-state={isSelected ? 'selected' : undefined}
198
- class="border-border/60 border-b last:border-0 {onSelect
199
- ? 'cursor-pointer hover:bg-[color-mix(in_oklch,var(--foreground)_4%,transparent)]'
200
- : ''} {isSelected ? 'bg-[color-mix(in_oklch,var(--primary)_10%,transparent)]' : ''}"
201
- style="{extraStyle}{isSelected ? ' ' + selectedShadow : ''}"
202
- onclick={onSelect ? () => onSelect(rowId) : undefined}
203
- onkeydown={onSelect
204
- ? (e) => {
205
- if (e.key === 'Enter' || e.key === ' ') {
206
- e.preventDefault();
207
- onSelect(rowId);
208
- }
207
+ <TableBody>
208
+ {#each rows as row (row.id)}
209
+ {@const originalRow = row.original}
210
+ {@const rowId = getRowId(originalRow)}
211
+ {@const isSelected = rowId === selectedId}
212
+ {@const isChecked = selectedSet.has(rowId)}
213
+ {@const extraStyle = getRowStyle ? getRowStyle(originalRow) : ''}
214
+ <TableRow
215
+ data-state={isSelected ? 'selected' : undefined}
216
+ class="border-border/60 border-b last:border-0 {onSelect
217
+ ? 'cursor-pointer hover:bg-[color-mix(in_oklch,var(--foreground)_4%,transparent)]'
218
+ : ''} {isSelected ? 'bg-[color-mix(in_oklch,var(--primary)_10%,transparent)]' : ''}"
219
+ style="{extraStyle}{isSelected ? ' ' + selectedShadow : ''}"
220
+ onclick={onSelect ? () => onSelect(rowId) : undefined}
221
+ onkeydown={onSelect
222
+ ? (e) => {
223
+ if (e.key === 'Enter' || e.key === ' ') {
224
+ e.preventDefault();
225
+ onSelect(rowId);
209
226
  }
210
- : undefined}
211
- tabindex={onSelect ? 0 : undefined}
212
- >
213
- {#if selectable}
214
- <TableCell class="w-10 px-3 py-1.5">
215
- <!-- The wrapper ONLY stops the event reaching the row, so ticking a
216
- checkbox never also opens the master-detail. The checkbox's own
217
- onCheckedChange is the single source of the selection change —
218
- handling the click here as well would toggle twice per click and
219
- net out to no selection at all. -->
220
- <div
221
- role="presentation"
222
- onclick={(e) => e.stopPropagation()}
223
- onkeydown={(e) => e.stopPropagation()}
224
- >
225
- <Checkbox
226
- checked={isChecked}
227
- onCheckedChange={(v) => setRowSelected(rowId, v)}
228
- aria-label="Select row"
229
- />
230
- </div>
231
- </TableCell>
232
- {/if}
233
- {#each row.getVisibleCells() as cell (cell.id)}
234
- {@const meta = cell.column.columnDef.meta}
235
- <TableCell
236
- class={cn('px-3 py-1.5 text-sm whitespace-nowrap', meta?.class, meta?.cellClass)}
227
+ }
228
+ : undefined}
229
+ tabindex={onSelect ? 0 : undefined}
230
+ >
231
+ {#if selectable}
232
+ <TableCell class="w-10 px-3 py-1.5">
233
+ <!-- The wrapper ONLY stops the event reaching the row, so ticking a
234
+ checkbox never also opens the master-detail. The checkbox's own
235
+ onCheckedChange is the single source of the selection change —
236
+ handling the click here as well would toggle twice per click and
237
+ net out to no selection at all. -->
238
+ <div
239
+ role="presentation"
240
+ onclick={(e) => e.stopPropagation()}
241
+ onkeydown={(e) => e.stopPropagation()}
237
242
  >
238
- <FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
239
- </TableCell>
240
- {/each}
241
- </TableRow>
242
- {/each}
243
- </TableBody>
244
- </TableRoot>
245
- </div>
243
+ <Checkbox
244
+ checked={isChecked}
245
+ onCheckedChange={(v) => setRowSelected(rowId, v)}
246
+ aria-label="Select row"
247
+ />
248
+ </div>
249
+ </TableCell>
250
+ {/if}
251
+ {#each row.getVisibleCells() as cell (cell.id)}
252
+ {@const meta = cell.column.columnDef.meta}
253
+ <TableCell
254
+ class={cn('px-3 py-1.5 text-sm whitespace-nowrap', meta?.class, meta?.cellClass)}
255
+ >
256
+ <FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
257
+ </TableCell>
258
+ {/each}
259
+ </TableRow>
260
+ {/each}
261
+ </TableBody>
262
+ </TableRoot>
246
263
  {/if}
247
264
  </div>
@@ -2,7 +2,7 @@ declare const InputGroupInput: import("svelte").Component<(Omit<import("svelte/e
2
2
  type: "file";
3
3
  files?: FileList;
4
4
  } | {
5
- type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "date" | "time" | "image" | "datetime-local" | "password" | "range" | "week";
5
+ type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "week" | "date" | "time" | "image" | "datetime-local" | "password" | "range";
6
6
  files?: undefined;
7
7
  })) & {
8
8
  ref?: HTMLElement | null | undefined;
@@ -1,79 +1,15 @@
1
1
  <script lang="ts">
2
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.
3
+ * ListShell — a module's working list, folded away and brought back by
4
+ * the app that owns it, beside the content it serves. Not an AppShell
5
+ * slot: `sidebar` was removed 2026.8.11 after two apps disagreed on
6
+ * where a module's own PAGES render (operator ruling 21/08/2026); a
7
+ * working list is data, not navigation, so it is a page-level companion
8
+ * like `SettingsShell`/`ContextColumn` — pair with `padded={false}` on
9
+ * AppShell, as `SettingsShell` requires. `open` is the caller's own
10
+ * bindable state, never persisted here; closed, this renders nothing,
11
+ * and reopening is the caller's own control, placed wherever the module
12
+ * chooses. Hidden below `md` at every state.
77
13
  */
78
14
  import type { Snippet } from 'svelte';
79
15
  import type { HTMLAttributes } from 'svelte/elements';
@@ -1,78 +1,14 @@
1
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.
2
+ * ListShell — a module's working list, folded away and brought back by
3
+ * the app that owns it, beside the content it serves. Not an AppShell
4
+ * slot: `sidebar` was removed 2026.8.11 after two apps disagreed on
5
+ * where a module's own PAGES render (operator ruling 21/08/2026); a
6
+ * working list is data, not navigation, so it is a page-level companion
7
+ * like `SettingsShell`/`ContextColumn` — pair with `padded={false}` on
8
+ * AppShell, as `SettingsShell` requires. `open` is the caller's own
9
+ * bindable state, never persisted here; closed, this renders nothing,
10
+ * and reopening is the caller's own control, placed wherever the module
11
+ * chooses. Hidden below `md` at every state.
76
12
  */
77
13
  import type { Snippet } from 'svelte';
78
14
  import type { HTMLAttributes } from 'svelte/elements';
@@ -98,6 +34,6 @@ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
98
34
  /** The list body: tabs, group headings, rows — entirely the module's own. */
99
35
  children: Snippet;
100
36
  };
101
- declare const ListShell: import("svelte").Component<$$ComponentProps, {}, "open" | "ref">;
37
+ declare const ListShell: import("svelte").Component<$$ComponentProps, {}, "ref" | "open">;
102
38
  type ListShell = ReturnType<typeof ListShell>;
103
39
  export default ListShell;
@@ -1,3 +1,3 @@
1
- declare const Select: import("svelte").Component<import("bits-ui").SelectRootPropsWithoutHTML, {}, "open" | "value">;
1
+ declare const Select: import("svelte").Component<import("bits-ui").SelectRootPropsWithoutHTML, {}, "value" | "open">;
2
2
  type Select = ReturnType<typeof Select>;
3
3
  export default Select;
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.20",
3
+ "version": "2026.9.22",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/radar-hooves/design-system.git"
9
+ "url": "git+https://github.com/radar-hooves/app-factory.git",
10
+ "directory": "kits/typescript/packages/ui"
10
11
  },
11
12
  "publishConfig": {
12
13
  "registry": "https://registry.npmjs.org",
13
14
  "access": "public",
14
- "provenance": false
15
+ "provenance": true
15
16
  },
16
17
  "files": [
17
18
  "bin",
@@ -37,6 +38,10 @@
37
38
  "types": "./dist/components/feedback/index.d.ts",
38
39
  "svelte": "./dist/components/feedback/index.js"
39
40
  },
41
+ "./alerts": {
42
+ "types": "./dist/components/alerts/index.d.ts",
43
+ "svelte": "./dist/components/alerts/index.js"
44
+ },
40
45
  "./*": {
41
46
  "types": "./dist/components/ui/*/index.d.ts",
42
47
  "svelte": "./dist/components/ui/*/index.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.20",
4
+ "version": "2026.9.22",
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": 61,
@@ -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.20`. 61 components, 17 situations.
4
+ Generated from `@poodle64/ui@2026.9.22`. 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