@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 +17 -16
- package/dist/components/alerts/alert-bell.svelte +177 -0
- package/dist/components/alerts/alert-bell.svelte.d.ts +16 -0
- package/dist/components/alerts/index.d.ts +2 -0
- package/dist/components/alerts/index.js +2 -0
- package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
- package/dist/components/ui/data-table-tanstack/data-table-tanstack.svelte +106 -89
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/list-shell/list-shell.svelte +10 -74
- package/dist/components/ui/list-shell/list-shell.svelte.d.ts +11 -75
- package/dist/components/ui/select/select.svelte.d.ts +1 -1
- package/package.json +8 -3
- package/registry/component-map.json +1 -1
- package/registry/component-map.md +1 -1
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;
|
|
@@ -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, {}, "
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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"
|
|
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
|
|
4
|
-
* app that owns it, beside the content it serves.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
3
|
-
* app that owns it, beside the content it serves.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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, {}, "
|
|
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, {}, "
|
|
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.
|
|
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/
|
|
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":
|
|
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
|
# @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.
|
|
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
|
|