@poodle64/ui 2026.9.17 → 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,53 @@
1
+ <script lang="ts" module>
2
+ export interface Fact {
3
+ /** Stable identity for #each; never rendered. */
4
+ key: string;
5
+ /** Uppercase eyebrow label. */
6
+ label: string;
7
+ /** The value. A long one (a sentence, an address) spans the row. */
8
+ value: string;
9
+ /** Render the value in the figures' face: mono, tabular. */
10
+ numeric?: boolean;
11
+ }
12
+ </script>
13
+
14
+ <script lang="ts">
15
+ import type { HTMLAttributes } from 'svelte/elements';
16
+ import { cn, type WithElementRef } from '../../../utils.js';
17
+
18
+ // A document's key facts read at a glance, INSIDE whatever card already
19
+ // holds them — eyebrow label over value, wrapped into a responsive grid,
20
+ // with no border or title of its own. StatList is this same situation as
21
+ // a titled card for the context column; reach for FactGrid instead where
22
+ // a Panel or DetailPanel already supplies the box, so nesting never
23
+ // doubles the border (godswood's FactGrid stop-gap, #839).
24
+ let {
25
+ facts,
26
+ class: klass = '',
27
+ ref = $bindable(null),
28
+ ...restProps
29
+ }: WithElementRef<HTMLAttributes<HTMLElement>> & {
30
+ facts: Fact[];
31
+ } = $props();
32
+
33
+ // A sentence takes the whole row; a short value shares it with its
34
+ // neighbours. 40 is the length the source stop-gap measured this against.
35
+ const LONG_VALUE = 40;
36
+ </script>
37
+
38
+ <dl
39
+ bind:this={ref}
40
+ class={cn('grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 xl:grid-cols-4', klass)}
41
+ {...restProps}
42
+ >
43
+ {#each facts as fact (fact.key)}
44
+ <div class={cn('min-w-0', fact.value.length > LONG_VALUE && 'col-span-full')}>
45
+ <dt class="text-muted-foreground text-2xs tracking-eyebrow font-medium uppercase">
46
+ {fact.label}
47
+ </dt>
48
+ <dd class={cn('mt-1 break-words', fact.numeric && 'font-mono tabular-nums')}>
49
+ {fact.value}
50
+ </dd>
51
+ </div>
52
+ {/each}
53
+ </dl>
@@ -0,0 +1,18 @@
1
+ export interface Fact {
2
+ /** Stable identity for #each; never rendered. */
3
+ key: string;
4
+ /** Uppercase eyebrow label. */
5
+ label: string;
6
+ /** The value. A long one (a sentence, an address) spans the row. */
7
+ value: string;
8
+ /** Render the value in the figures' face: mono, tabular. */
9
+ numeric?: boolean;
10
+ }
11
+ import type { HTMLAttributes } from 'svelte/elements';
12
+ import { type WithElementRef } from '../../../utils.js';
13
+ type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLElement>> & {
14
+ facts: Fact[];
15
+ };
16
+ declare const FactGrid: import("svelte").Component<$$ComponentProps, {}, "ref">;
17
+ type FactGrid = ReturnType<typeof FactGrid>;
18
+ export default FactGrid;
@@ -0,0 +1,3 @@
1
+ export { default as FactGrid } from './fact-grid.svelte';
2
+ export { default } from './fact-grid.svelte';
3
+ export type { Fact } from './fact-grid.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as FactGrid } from './fact-grid.svelte';
2
+ export { default } from './fact-grid.svelte';
@@ -22,7 +22,24 @@
22
22
 
23
23
  <Tooltip.Provider delayDuration={150}>
24
24
  <Tooltip.Root>
25
- <Tooltip.Trigger class="inline-flex cursor-help items-center align-middle">
25
+ <!--
26
+ The icon trigger takes a 44x44 hit area (`size-11`) and hands the space
27
+ straight back (`-m-3.5`), so the glyph, the row it sits in and the
28
+ spacing around it are all exactly as before and only a thumb can tell
29
+ the difference. Measured on one consumer at 390 px: this button was
30
+ 16x16 on every page header carrying `info`, well under Apple's 44 pt
31
+ floor, and it is the package's button on every consumer's page.
32
+
33
+ Only the bare icon form gets it. Where `children` are passed the trigger
34
+ wraps an affordance the app already sized — a status dot inline in a
35
+ sentence, a chip in a row — and a 44 px box around one of those moves
36
+ the layout it was placed in.
37
+ -->
38
+ <Tooltip.Trigger
39
+ class="inline-flex cursor-help items-center align-middle {children
40
+ ? ''
41
+ : '-m-3.5 size-11 justify-center'}"
42
+ >
26
43
  {#if children}
27
44
  {@render children()}
28
45
  {:else}
@@ -0,0 +1,3 @@
1
+ export { default as PageCanvas } from './page-canvas.svelte';
2
+ export { default } from './page-canvas.svelte';
3
+ export type { PageCanvasRegion, PageCanvasRenderInfo } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { default as PageCanvas } from './page-canvas.svelte';
2
+ export { default } from './page-canvas.svelte';
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.17",
3
+ "version": "2026.9.18",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.17",
4
+ "version": "2026.9.18",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 58,
8
- "situationCount": 16
7
+ "componentCount": 60,
8
+ "situationCount": 17
9
9
  },
10
10
  "situations": [
11
11
  {
@@ -33,6 +33,13 @@
33
33
  "import": "@poodle64/ui/stat-list",
34
34
  "props": "items, title?, info?",
35
35
  "insteadOf": "a hand-built <dl> or a stack of label/value <div>s"
36
+ },
37
+ {
38
+ "name": "FactGrid",
39
+ "dir": "fact-grid",
40
+ "import": "@poodle64/ui/fact-grid",
41
+ "props": "facts",
42
+ "insteadOf": "the same figures inside a Panel or DetailPanel that already supplies the card — StatList's own border would nest a box in a box; FactGrid is the borderless, wrapping-grid answer for that spot"
36
43
  }
37
44
  ]
38
45
  },
@@ -274,6 +281,20 @@
274
281
  }
275
282
  ]
276
283
  },
284
+ {
285
+ "key": "document-evidence",
286
+ "title": "A document a machine read, with evidence outlined",
287
+ "description": "One page at a time, fitted to its pane both ways so an A4 statement and a tall receipt alike fill it, with what a reading points at outlined on the page — a hovered or clicked region with its label, a dashed 'where it looked' set, and a focus that zooms in and centres. The app supplies the page render (image, PDF canvas, anything) and the region coordinates as page fractions; this owns the fit/zoom/pan and the overlay only.",
288
+ "components": [
289
+ {
290
+ "name": "PageCanvas",
291
+ "dir": "page-canvas",
292
+ "import": "@poodle64/ui/page-canvas",
293
+ "props": "pageCount, page, currentPage?, regions?, lookedRegions?, activeRegionId?, focusedRegionId?, zoom?",
294
+ "insteadOf": "a hand-built page viewer per app (godswood's DocumentViewer, pebblestone's PdfViewer) each re-deriving fit/zoom/pan, now with no way to outline what was read (#839)"
295
+ }
296
+ ]
297
+ },
277
298
  {
278
299
  "key": "page-frame",
279
300
  "title": "The frame every route shares",
@@ -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.17`. 58 components, 16 situations.
4
+ Generated from `@poodle64/ui@2026.9.18`. 60 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
 
@@ -23,6 +23,7 @@ _A block of plain figures read at a glance — an 'at a glance' strip — where
23
23
  | Component | Import | Key props | Reach for it instead of |
24
24
  | --- | --- | --- | --- |
25
25
  | `StatList` | `@poodle64/ui/stat-list` | `items, title?, info?` | a hand-built <dl> or a stack of label/value <div>s |
26
+ | `FactGrid` | `@poodle64/ui/fact-grid` | `facts` | the same figures inside a Panel or DetailPanel that already supplies the card — StatList's own border would nest a box in a box; FactGrid is the borderless, wrapping-grid answer for that spot |
26
27
 
27
28
  ## Comparing magnitudes across rows
28
29
 
@@ -118,6 +119,14 @@ _The estate's shared document-library surfaces — a faceted catalogue, one coll
118
119
  | `DocumentDetail` | `@poodle64/ui/document-detail` | `document, loading?, error?, collectionHref?, onOpenCollection?, actions?, children?` | a bespoke document page: identity dl, locations, tags and memberships hand-laid-out per app |
119
120
  | `SearchResults` | `@poodle64/ui/search-results` | `results, query?, total?, loading?, error?, resultHref?, onOpen?, onRetry?` | a hand-built ranked hit list minting its own highlight and relevance styling |
120
121
 
122
+ ## A document a machine read, with evidence outlined
123
+
124
+ _One page at a time, fitted to its pane both ways so an A4 statement and a tall receipt alike fill it, with what a reading points at outlined on the page — a hovered or clicked region with its label, a dashed 'where it looked' set, and a focus that zooms in and centres. The app supplies the page render (image, PDF canvas, anything) and the region coordinates as page fractions; this owns the fit/zoom/pan and the overlay only._
125
+
126
+ | Component | Import | Key props | Reach for it instead of |
127
+ | --- | --- | --- | --- |
128
+ | `PageCanvas` | `@poodle64/ui/page-canvas` | `pageCount, page, currentPage?, regions?, lookedRegions?, activeRegionId?, focusedRegionId?, zoom?` | a hand-built page viewer per app (godswood's DocumentViewer, pebblestone's PdfViewer) each re-deriving fit/zoom/pan, now with no way to outline what was read (#839) |
129
+
121
130
  ## The frame every route shares
122
131
 
123
132
  _The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column._