@poodle64/ui 2026.9.16 → 2026.9.18

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.
@@ -0,0 +1,374 @@
1
+ <script lang="ts">
2
+ // One page at a time, fitted to its pane both ways so an A4 statement and a
3
+ // tall phone-scanned receipt both fill it — the shared engine behind
4
+ // godswood's run view and pebblestone's invoice/quote panes alike
5
+ // (radar-hooves/godswood#839). The package draws no page itself: a
6
+ // consumer supplies image URLs, a PDF canvas, or any other render through
7
+ // the `page` snippet, and reports each page's own intrinsic size once it
8
+ // is known (an image's `onload`, a PDF viewport) via `reportSize` — this
9
+ // component owns only the fit/zoom/pan/pager arithmetic and the evidence
10
+ // overlay drawn on top of it.
11
+ //
12
+ // A region is a box in PAGE FRACTIONS (0–1 each way), never a pixel tied
13
+ // to one raster's resolution, so the same shape works whatever the page's
14
+ // native size turns out to be. `activeRegionId` (hover, from a list the
15
+ // consumer renders beside the page) and `focusedRegionId` (click) both
16
+ // show the same solid outline; a click also zooms in on the region and
17
+ // centres it, leaving room for its label beside it where there is room
18
+ // and above it where there is not — the same room-check either way, since
19
+ // pinning only changes how much of the pane the page takes up.
20
+ // `lookedRegions` draws a further, non-interactive dashed set — "where it
21
+ // looked" — for the current page only.
22
+ import type { Snippet } from 'svelte';
23
+ import type { HTMLAttributes } from 'svelte/elements';
24
+ import { cn, type WithElementRef } from '../../../utils.js';
25
+ import { Skeleton } from '../skeleton/index.js';
26
+ import { Button } from '../button/index.js';
27
+ import Plus from '@lucide/svelte/icons/plus';
28
+ import Minus from '@lucide/svelte/icons/minus';
29
+ import Maximize from '@lucide/svelte/icons/maximize';
30
+ import ChevronLeft from '@lucide/svelte/icons/chevron-left';
31
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
32
+ import type { PageCanvasRegion, PageCanvasRenderInfo } from './types.js';
33
+
34
+ let {
35
+ pageCount,
36
+ currentPage = $bindable(1),
37
+ page,
38
+ regions = [],
39
+ lookedRegions = [],
40
+ activeRegionId = $bindable(null),
41
+ focusedRegionId = $bindable(null),
42
+ zoom = $bindable(1),
43
+ class: klass = '',
44
+ ref = $bindable(null),
45
+ ...restProps
46
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
47
+ pageCount: number;
48
+ /** 1-based; bindable so a caller can drive it from outside (a thumbnail rail). */
49
+ currentPage?: number;
50
+ /** Renders one page's content at the size this canvas computes for it. */
51
+ page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
52
+ /** Every region across every page; only the current page's are drawn. */
53
+ regions?: PageCanvasRegion[];
54
+ /** The non-interactive dashed "where it looked" set for the current page. */
55
+ lookedRegions?: PageCanvasRegion[];
56
+ /** Hovered in a list beside the page. */
57
+ activeRegionId?: string | null;
58
+ /** Clicked; zooms in on and centres the region. */
59
+ focusedRegionId?: string | null;
60
+ zoom?: number;
61
+ } = $props();
62
+
63
+ // The stage's own reserved space: PAD_X across both edges, PAD_TOP above
64
+ // the page, PAD_BOTTOM below it — room for the control bar so it sits
65
+ // clear of the fitted page rather than over its corners. LABEL_RESERVE is
66
+ // how much room beside the page a region's label needs to sit in it
67
+ // rather than above it.
68
+ const PAD_X = 24;
69
+ const PAD_TOP = 12;
70
+ const PAD_BOTTOM = 64;
71
+ const LABEL_RESERVE = 300;
72
+ const ZOOM_MIN = 0.5;
73
+ const ZOOM_MAX = 3;
74
+ const ZOOM_STEP = 0.25;
75
+ // How far a focused region may zoom past the whole-page fit, and how much
76
+ // smaller than that a beside-the-page layout is still allowed to be
77
+ // before it gives up the side column and fills the pane's width instead.
78
+ const FOCUS_MAX_OVER_FIT = 3.4;
79
+ const FOCUS_MIN_FOR_SIDE_LABEL = 1.2;
80
+
81
+ let stageW = $state(0);
82
+ let stageH = $state(0);
83
+ let natural = $state<Record<number, { width: number; height: number }>>({});
84
+
85
+ function reportSize(pageNumber: number) {
86
+ return (width: number, height: number) => {
87
+ if (width <= 0 || height <= 0) return;
88
+ const known = natural[pageNumber];
89
+ if (known && known.width === width && known.height === height) return;
90
+ natural[pageNumber] = { width, height };
91
+ };
92
+ }
93
+
94
+ function clampZoom(value: number): number {
95
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 100) / 100));
96
+ }
97
+ function zoomIn() {
98
+ zoom = clampZoom(zoom + ZOOM_STEP);
99
+ }
100
+ function zoomOut() {
101
+ zoom = clampZoom(zoom - ZOOM_STEP);
102
+ }
103
+
104
+ function goToPage(target: number) {
105
+ const next = Math.min(pageCount, Math.max(1, target));
106
+ if (next === currentPage) return;
107
+ currentPage = next;
108
+ zoom = 1;
109
+ focusedRegionId = null;
110
+ }
111
+ function prevPage() {
112
+ goToPage(currentPage - 1);
113
+ }
114
+ function nextPage() {
115
+ goToPage(currentPage + 1);
116
+ }
117
+ function backToWholePage() {
118
+ focusedRegionId = null;
119
+ }
120
+
121
+ // Focusing a region on another page turns to that page — the approved
122
+ // canvas's own rule (`pg = pinned ? line.page : st.pg`). Hover does not:
123
+ // only a click is decisive enough to leave the page the caller is reading.
124
+ $effect(() => {
125
+ if (focusedRegionId === null) return;
126
+ const region = regions.find((r) => r.id === focusedRegionId);
127
+ if (region && region.page !== currentPage) currentPage = region.page;
128
+ });
129
+
130
+ function handleWheel(event: WheelEvent) {
131
+ if (!event.ctrlKey && !event.metaKey) return;
132
+ event.preventDefault();
133
+ if (event.deltaY < 0) zoomIn();
134
+ else zoomOut();
135
+ }
136
+ function handleKeydown(event: KeyboardEvent) {
137
+ if (event.key === 'ArrowLeft') {
138
+ event.preventDefault();
139
+ prevPage();
140
+ } else if (event.key === 'ArrowRight') {
141
+ event.preventDefault();
142
+ nextPage();
143
+ } else if (event.key === '+' || event.key === '=') {
144
+ event.preventDefault();
145
+ zoomIn();
146
+ } else if (event.key === '-') {
147
+ event.preventDefault();
148
+ zoomOut();
149
+ } else if (event.key === '0') {
150
+ event.preventDefault();
151
+ zoom = 1;
152
+ } else if (event.key === 'Escape' && focusedRegionId !== null) {
153
+ event.preventDefault();
154
+ backToWholePage();
155
+ }
156
+ }
157
+
158
+ const size = $derived(natural[currentPage]);
159
+ const focusedRegion = $derived(
160
+ focusedRegionId
161
+ ? (regions.find((r) => r.id === focusedRegionId && r.page === currentPage) ?? null)
162
+ : null
163
+ );
164
+ const shownRegionId = $derived(focusedRegionId ?? activeRegionId);
165
+ const shownRegion = $derived(
166
+ shownRegionId ? (regions.find((r) => r.id === shownRegionId && r.page === currentPage) ?? null) : null
167
+ );
168
+ const lookedOnPage = $derived(lookedRegions.filter((r) => r.page === currentPage));
169
+
170
+ // The whole-page fit, then a focused region's own zoom-and-centre, ported
171
+ // from the fat-controller run-view design canvas (godswood
172
+ // docs/design/fat-controller/project/Run-*.dc.html, `renderVals`). Past
173
+ // the fit, the frame outgrows the pane; `left`/`top` are floored at 0 so
174
+ // the page's own top-left corner is always the reachable start of the
175
+ // scrollable stage below rather than an offset an `overflow: auto` box
176
+ // can never scroll to (a negative position is outside its scroll range),
177
+ // and whatever spills past the pane's other edges scrolls into view there
178
+ // instead of the centring maths trying to pre-empt every edge itself.
179
+ const layout = $derived.by(() => {
180
+ const n = size;
181
+ if (!n || stageW <= PAD_X || stageH <= PAD_TOP + PAD_BOTTOM) return null;
182
+ const fit = Math.min((stageW - PAD_X) / n.width, (stageH - PAD_TOP - PAD_BOTTOM) / n.height);
183
+ let scale = fit * zoom;
184
+ let top = PAD_TOP;
185
+ let left = (stageW - n.width * scale) / 2;
186
+ let tagRight = stageW - n.width * scale >= LABEL_RESERVE;
187
+ if (focusedRegion) {
188
+ let s = Math.min(fit * FOCUS_MAX_OVER_FIT, (stageW - LABEL_RESERVE - PAD_X) / n.width);
189
+ tagRight = s >= fit * FOCUS_MIN_FOR_SIDE_LABEL;
190
+ if (!tagRight) s = Math.min(fit * FOCUS_MAX_OVER_FIT, (stageW - PAD_X) / n.width);
191
+ scale = Math.max(s, scale);
192
+ const cy = (focusedRegion.box.y + focusedRegion.box.h / 2) * n.height;
193
+ top = Math.min(
194
+ PAD_TOP,
195
+ Math.max(stageH - PAD_BOTTOM - n.height * scale, (stageH - PAD_BOTTOM) / 2 - cy * scale)
196
+ );
197
+ left = tagRight
198
+ ? Math.max(PAD_X / 2, (stageW - LABEL_RESERVE - n.width * scale) / 2)
199
+ : (stageW - n.width * scale) / 2;
200
+ }
201
+ return {
202
+ scale,
203
+ top: Math.max(0, top),
204
+ left: Math.max(0, left),
205
+ tagRight,
206
+ width: n.width * scale,
207
+ height: n.height * scale
208
+ };
209
+ });
210
+
211
+ const renderInfo = $derived<PageCanvasRenderInfo>({
212
+ size: layout ? { width: layout.width, height: layout.height } : { width: 0, height: 0 },
213
+ reportSize: reportSize(currentPage)
214
+ });
215
+
216
+ const frameStyle = $derived(
217
+ layout
218
+ ? `position:absolute; left:${layout.left}px; top:${layout.top}px; width:${layout.width}px; height:${layout.height}px; transition: left 280ms cubic-bezier(0.2,0,0,1), top 280ms cubic-bezier(0.2,0,0,1), width 280ms cubic-bezier(0.2,0,0,1), height 280ms cubic-bezier(0.2,0,0,1);`
219
+ : `position:absolute; left:0; top:0; opacity:0; pointer-events:none;`
220
+ );
221
+
222
+ function regionBoxStyle(r: PageCanvasRegion): string {
223
+ const n = size!;
224
+ const l = layout!;
225
+ return `position:absolute; left:${r.box.x * n.width * l.scale}px; top:${r.box.y * n.height * l.scale}px; width:${r.box.w * n.width * l.scale}px; height:${r.box.h * n.height * l.scale}px;`;
226
+ }
227
+
228
+ function toggleFocus(r: PageCanvasRegion) {
229
+ focusedRegionId = focusedRegionId === r.id ? null : r.id;
230
+ }
231
+ function onRegionKeydown(event: KeyboardEvent, r: PageCanvasRegion) {
232
+ if (event.key === 'Enter' || event.key === ' ') {
233
+ event.preventDefault();
234
+ toggleFocus(r);
235
+ }
236
+ }
237
+ </script>
238
+
239
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
240
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
241
+ <div
242
+ bind:this={ref}
243
+ class={cn('relative outline-none', klass)}
244
+ bind:clientWidth={stageW}
245
+ bind:clientHeight={stageH}
246
+ onwheel={handleWheel}
247
+ onkeydown={handleKeydown}
248
+ tabindex="0"
249
+ role="document"
250
+ aria-label="Document page; use arrow keys to change page"
251
+ {...restProps}
252
+ >
253
+ {#if pageCount === 0}
254
+ <div class="text-muted-foreground flex h-full items-center justify-center text-sm">
255
+ No pages available
256
+ </div>
257
+ {:else}
258
+ <!--
259
+ The controls below are siblings of this scroller, not children of it,
260
+ so a page zoomed or focused past the pane scrolls under them rather
261
+ than carrying them away with it.
262
+ -->
263
+ <div class="absolute inset-0 overflow-auto">
264
+ <div style={frameStyle}>
265
+ {#if !size}
266
+ <Skeleton class="absolute inset-0" />
267
+ {/if}
268
+ {@render page(currentPage, renderInfo)}
269
+
270
+ {#if layout}
271
+ {#each lookedOnPage as region (region.id)}
272
+ <div
273
+ class="border-muted-foreground/70 bg-transparent pointer-events-none absolute rounded-sm border-2 border-dashed"
274
+ style={regionBoxStyle(region)}
275
+ aria-hidden="true"
276
+ >
277
+ {#if region.label}
278
+ <span
279
+ class="bg-card border-border text-muted-foreground absolute rounded-md border px-2 py-0.5 text-xs whitespace-nowrap"
280
+ style={layout.tagRight
281
+ ? `left:${layout.width * (1 - region.box.x) + 10}px; top:0;`
282
+ : 'left:4px; top:4px;'}
283
+ >
284
+ {region.label}
285
+ </span>
286
+ {/if}
287
+ </div>
288
+ {/each}
289
+
290
+ {#if shownRegion}
291
+ <div style={regionBoxStyle(shownRegion)} class="absolute">
292
+ <span
293
+ role="button"
294
+ tabindex="0"
295
+ class="border-primary bg-primary/10 absolute inset-0 cursor-pointer rounded-sm border-2"
296
+ style="transform: rotate({shownRegion.rotateDeg ?? 0}deg); transition: all 160ms ease;"
297
+ onclick={() => toggleFocus(shownRegion)}
298
+ onkeydown={(e) => onRegionKeydown(e, shownRegion)}
299
+ aria-label={shownRegion.label
300
+ ? `Focus the outlined region: ${shownRegion.label}`
301
+ : 'Focus the outlined region'}
302
+ ></span>
303
+ {#if shownRegion.label}
304
+ <span
305
+ class="bg-card border-border text-foreground absolute rounded-md border px-2.5 py-1 text-xs font-medium whitespace-nowrap"
306
+ style={layout.tagRight
307
+ ? 'left: calc(100% + 12px); top: 50%; transform: translateY(-50%);'
308
+ : 'left: 0; bottom: calc(100% + 8px);'}
309
+ >
310
+ {shownRegion.label}
311
+ </span>
312
+ {/if}
313
+ </div>
314
+ {/if}
315
+ {/if}
316
+ </div>
317
+ </div>
318
+
319
+ <div class="absolute bottom-3 left-3 flex items-center gap-2">
320
+ {#if focusedRegion}
321
+ <Button variant="outline" size="sm" onclick={backToWholePage}>Whole page</Button>
322
+ {:else if pageCount > 1}
323
+ <div class="bg-background flex items-center gap-0.5 rounded-lg p-1 shadow-sm">
324
+ <Button
325
+ variant="outline"
326
+ size="icon"
327
+ class="size-8"
328
+ onclick={prevPage}
329
+ disabled={currentPage <= 1}
330
+ aria-label="Previous page"
331
+ >
332
+ <ChevronLeft class="size-4" />
333
+ </Button>
334
+ <span class="text-foreground min-w-16 text-center text-xs font-medium tabular-nums">
335
+ {currentPage} / {pageCount}
336
+ </span>
337
+ <Button
338
+ variant="outline"
339
+ size="icon"
340
+ class="size-8"
341
+ onclick={nextPage}
342
+ disabled={currentPage >= pageCount}
343
+ aria-label="Next page"
344
+ >
345
+ <ChevronRight class="size-4" />
346
+ </Button>
347
+ </div>
348
+ {/if}
349
+ </div>
350
+
351
+ <div
352
+ class="bg-background absolute right-3 bottom-3 flex items-center gap-0.5 rounded-lg p-1 shadow-sm"
353
+ >
354
+ <Button variant="outline" size="icon" class="size-8" onclick={zoomOut} aria-label="Zoom out">
355
+ <Minus class="size-4" />
356
+ </Button>
357
+ <span class="text-foreground min-w-11 text-center text-xs font-medium tabular-nums">
358
+ {Math.round(zoom * 100)}%
359
+ </span>
360
+ <Button variant="outline" size="icon" class="size-8" onclick={zoomIn} aria-label="Zoom in">
361
+ <Plus class="size-4" />
362
+ </Button>
363
+ <Button
364
+ variant="outline"
365
+ size="icon"
366
+ class="size-8"
367
+ onclick={() => (zoom = 1)}
368
+ aria-label="Fit the page"
369
+ >
370
+ <Maximize class="size-3.5" />
371
+ </Button>
372
+ </div>
373
+ {/if}
374
+ </div>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import { type WithElementRef } from '../../../utils.js';
4
+ import type { PageCanvasRegion, PageCanvasRenderInfo } from './types.js';
5
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
6
+ pageCount: number;
7
+ /** 1-based; bindable so a caller can drive it from outside (a thumbnail rail). */
8
+ currentPage?: number;
9
+ /** Renders one page's content at the size this canvas computes for it. */
10
+ page: Snippet<[pageNumber: number, info: PageCanvasRenderInfo]>;
11
+ /** Every region across every page; only the current page's are drawn. */
12
+ regions?: PageCanvasRegion[];
13
+ /** The non-interactive dashed "where it looked" set for the current page. */
14
+ lookedRegions?: PageCanvasRegion[];
15
+ /** Hovered in a list beside the page. */
16
+ activeRegionId?: string | null;
17
+ /** Clicked; zooms in on and centres the region. */
18
+ focusedRegionId?: string | null;
19
+ zoom?: number;
20
+ };
21
+ declare const PageCanvas: import("svelte").Component<$$ComponentProps, {}, "ref" | "currentPage" | "activeRegionId" | "focusedRegionId" | "zoom">;
22
+ type PageCanvas = ReturnType<typeof PageCanvas>;
23
+ export default PageCanvas;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The shared evidence vocabulary — plain shapes a consuming app maps a
3
+ * machine reading's own coordinates into. This package holds no image
4
+ * decoder, no PDF renderer and no OCR geometry; a region's box is a fraction
5
+ * of the page (0–1 each way), never a pixel tied to one raster's resolution,
6
+ * so the same shape describes an A4 page and a phone-scanned receipt alike.
7
+ */
8
+ /** One outlined place on a page — evidence for something the app read there. */
9
+ export interface PageCanvasRegion {
10
+ /** Stable identity: bind `activeRegionId` / `focusedRegionId` to this. */
11
+ id: string;
12
+ /** 1-based page number this region belongs to. */
13
+ page: number;
14
+ /** The region's box, in fractions (0–1) of the page's own width/height. */
15
+ box: {
16
+ x: number;
17
+ y: number;
18
+ w: number;
19
+ h: number;
20
+ };
21
+ /** A few degrees of tilt, for a phone-scanned page that slopes. */
22
+ rotateDeg?: number;
23
+ /** Shown beside the outlined region where there is room, above it where there is not. */
24
+ label?: string;
25
+ }
26
+ /** What the canvas hands the `page` snippet for one render. */
27
+ export interface PageCanvasRenderInfo {
28
+ /** The box to render at, in CSS pixels; {0, 0} until the page's own size is known. */
29
+ size: {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ /** Call once the page's intrinsic size is known (an image's onload, a PDF viewport). */
34
+ reportSize: (width: number, height: number) => void;
35
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The shared evidence vocabulary — plain shapes a consuming app maps a
3
+ * machine reading's own coordinates into. This package holds no image
4
+ * decoder, no PDF renderer and no OCR geometry; a region's box is a fraction
5
+ * of the page (0–1 each way), never a pixel tied to one raster's resolution,
6
+ * so the same shape describes an A4 page and a phone-scanned receipt alike.
7
+ */
8
+ export {};
@@ -1,17 +1,28 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet, Component } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import type { WithElementRef } from '../../../utils.js';
4
+ import { cn, type WithElementRef } from '../../../utils.js';
5
5
 
6
6
  // The generic titled card. Where DetailPanel is the *entity* surface (and owns
7
7
  // a status and a close affordance), Panel is the plain sectioning card any
8
8
  // route reaches for: an optional header with icon, subtitle and trailing
9
9
  // actions, over a body that can opt out of padding for a flush table.
10
+ //
11
+ // It is also the settings SECTION, and there is no second component for that.
12
+ // A `SettingsSection` would have been this card with a different name — same
13
+ // surface, same header, same title — and the estate would then hold two
14
+ // answers to "a titled section of a route", which is the drift this package
15
+ // exists to end. What settings actually needed that this did not have is three
16
+ // props, and all three serve any route: a description that wraps, a footer for
17
+ // the Save/Cancel pair, and a destructive tone for a danger zone.
10
18
  let {
11
19
  title,
12
20
  subtitle,
21
+ description,
13
22
  icon: Icon,
14
23
  action,
24
+ footer,
25
+ tone = 'default',
15
26
  children,
16
27
  pad = true,
17
28
  class: klass = '',
@@ -19,29 +30,94 @@
19
30
  ...restProps
20
31
  }: WithElementRef<HTMLAttributes<HTMLElement>> & {
21
32
  title?: string;
33
+ /**
34
+ * A one-line qualifier beside the title. It TRUNCATES, and every existing
35
+ * call site is written against that: a panel header is one rhythm, and a
36
+ * subtitle that wrapped would change it everywhere at once.
37
+ *
38
+ * For the sentence or two that explains what the section is for, use
39
+ * `description`, which wraps. Carry one or the other, not both.
40
+ */
22
41
  subtitle?: string;
42
+ /**
43
+ * The sentence or two under the title, wrapped, explaining what this
44
+ * section does or what changing it costs.
45
+ *
46
+ * PageHeader deliberately has no equivalent — a page's long explanation
47
+ * goes in its `info` tip so no route carries a standing explainer banner —
48
+ * and that position does not carry down to here. A settings section's
49
+ * description IS the setting's meaning ("who can approve a bill, and up to
50
+ * what value"), and hiding it behind an (i) on the one destination whose
51
+ * whole job is explaining consequential toggles is worse than the banner
52
+ * that rule was written against. GitHub, Linear, Vercel and Stripe all
53
+ * wrap a description under the section title.
54
+ */
55
+ description?: string;
23
56
  icon?: Component<{ class?: string }>;
24
57
  action?: Snippet;
58
+ /**
59
+ * The section's actions — a Save/Cancel pair — on a top-bordered muted
60
+ * strip at the foot, right-aligned. Same treatment as CardFooter's, so a
61
+ * panel's foot and a card's foot are the same foot.
62
+ */
63
+ footer?: Snippet;
64
+ /**
65
+ * `destructive` is the danger zone: a section whose controls delete or
66
+ * revoke something. It moves the rule and the title to the destructive
67
+ * ink — the treatment Alert and Button already use for it — and nothing
68
+ * else, so the section is marked without being shouted.
69
+ */
70
+ tone?: 'default' | 'destructive';
25
71
  children: Snippet;
26
72
  pad?: boolean;
27
73
  } = $props();
74
+
75
+ const destructive = $derived(tone === 'destructive');
28
76
  </script>
29
77
 
30
78
  <section
31
79
  bind:this={ref}
32
- class="bg-card border-border ds-edge flex flex-col overflow-hidden rounded-lg border {klass}"
80
+ data-tone={tone === 'default' ? undefined : tone}
81
+ class={cn(
82
+ 'bg-card ds-edge flex flex-col overflow-hidden rounded-lg border',
83
+ destructive ? 'border-destructive/40' : 'border-border',
84
+ klass
85
+ )}
33
86
  {...restProps}
34
87
  >
35
88
  {#if title}
36
- <header class="border-border flex items-center gap-2.5 border-b px-4 py-3">
89
+ <!--
90
+ `items-start` once a description is present: a wrapping description makes
91
+ the title block taller than the action row, and centring the actions
92
+ against three lines of prose floats them in the middle of the header.
93
+ -->
94
+ <header
95
+ class={cn(
96
+ 'flex gap-2.5 border-b px-4 py-3',
97
+ description ? 'items-start' : 'items-center',
98
+ destructive ? 'border-destructive/25' : 'border-border'
99
+ )}
100
+ >
37
101
  {#if Icon}
38
- <Icon class="text-muted-foreground size-4" />
102
+ <Icon
103
+ class={cn('size-4', destructive ? 'text-destructive' : 'text-muted-foreground')}
104
+ />
39
105
  {/if}
40
106
  <div class="min-w-0">
41
- <h2 class="text-body leading-tight font-semibold tracking-tight">{title}</h2>
107
+ <h2
108
+ class={cn(
109
+ 'text-body leading-tight font-semibold tracking-tight',
110
+ destructive && 'text-destructive'
111
+ )}
112
+ >
113
+ {title}
114
+ </h2>
42
115
  {#if subtitle}
43
116
  <p class="text-muted-foreground truncate text-xs">{subtitle}</p>
44
117
  {/if}
118
+ {#if description}
119
+ <p class="text-muted-foreground mt-1 text-sm">{description}</p>
120
+ {/if}
45
121
  </div>
46
122
  {#if action}
47
123
  <div class="ml-auto flex items-center gap-1.5">{@render action()}</div>
@@ -51,4 +127,20 @@
51
127
  <div class={pad ? 'p-4' : ''}>
52
128
  {@render children()}
53
129
  </div>
130
+ {#if footer}
131
+ <!--
132
+ `mt-auto` so a footer sits at the bottom of a panel that has been given a
133
+ height (a fixed-height column, a grid row) rather than floating under
134
+ short content.
135
+ -->
136
+ <div
137
+ class={cn(
138
+ 'bg-muted/50 mt-auto flex items-center justify-end gap-2 border-t px-4 py-3',
139
+ destructive ? 'border-destructive/25' : 'border-border'
140
+ )}
141
+ data-slot="panel-footer"
142
+ >
143
+ {@render footer()}
144
+ </div>
145
+ {/if}
54
146
  </section>
@@ -1,13 +1,48 @@
1
1
  import type { Snippet, Component } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import type { WithElementRef } from '../../../utils.js';
3
+ import { type WithElementRef } from '../../../utils.js';
4
4
  type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
5
5
  title?: string;
6
+ /**
7
+ * A one-line qualifier beside the title. It TRUNCATES, and every existing
8
+ * call site is written against that: a panel header is one rhythm, and a
9
+ * subtitle that wrapped would change it everywhere at once.
10
+ *
11
+ * For the sentence or two that explains what the section is for, use
12
+ * `description`, which wraps. Carry one or the other, not both.
13
+ */
6
14
  subtitle?: string;
15
+ /**
16
+ * The sentence or two under the title, wrapped, explaining what this
17
+ * section does or what changing it costs.
18
+ *
19
+ * PageHeader deliberately has no equivalent — a page's long explanation
20
+ * goes in its `info` tip so no route carries a standing explainer banner —
21
+ * and that position does not carry down to here. A settings section's
22
+ * description IS the setting's meaning ("who can approve a bill, and up to
23
+ * what value"), and hiding it behind an (i) on the one destination whose
24
+ * whole job is explaining consequential toggles is worse than the banner
25
+ * that rule was written against. GitHub, Linear, Vercel and Stripe all
26
+ * wrap a description under the section title.
27
+ */
28
+ description?: string;
7
29
  icon?: Component<{
8
30
  class?: string;
9
31
  }>;
10
32
  action?: Snippet;
33
+ /**
34
+ * The section's actions — a Save/Cancel pair — on a top-bordered muted
35
+ * strip at the foot, right-aligned. Same treatment as CardFooter's, so a
36
+ * panel's foot and a card's foot are the same foot.
37
+ */
38
+ footer?: Snippet;
39
+ /**
40
+ * `destructive` is the danger zone: a section whose controls delete or
41
+ * revoke something. It moves the rule and the title to the destructive
42
+ * ink — the treatment Alert and Button already use for it — and nothing
43
+ * else, so the section is marked without being shouted.
44
+ */
45
+ tone?: 'default' | 'destructive';
11
46
  children: Snippet;
12
47
  pad?: boolean;
13
48
  };
@@ -0,0 +1,2 @@
1
+ export { default as SettingsShell } from './settings-shell.svelte';
2
+ export { default } from './settings-shell.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as SettingsShell } from './settings-shell.svelte';
2
+ export { default } from './settings-shell.svelte';