@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.
- package/dist/components/ui/fact-grid/fact-grid.svelte +53 -0
- package/dist/components/ui/fact-grid/fact-grid.svelte.d.ts +18 -0
- package/dist/components/ui/fact-grid/index.d.ts +3 -0
- package/dist/components/ui/fact-grid/index.js +2 -0
- package/dist/components/ui/info-tip/info-tip.svelte +18 -1
- package/dist/components/ui/page-canvas/index.d.ts +3 -0
- package/dist/components/ui/page-canvas/index.js +2 -0
- package/dist/components/ui/page-canvas/page-canvas.svelte +374 -0
- package/dist/components/ui/page-canvas/page-canvas.svelte.d.ts +23 -0
- package/dist/components/ui/page-canvas/types.d.ts +35 -0
- package/dist/components/ui/page-canvas/types.js +8 -0
- package/package.json +1 -1
- package/registry/component-map.json +24 -3
- package/registry/component-map.md +10 -1
|
@@ -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;
|
|
@@ -22,7 +22,24 @@
|
|
|
22
22
|
|
|
23
23
|
<Tooltip.Provider delayDuration={150}>
|
|
24
24
|
<Tooltip.Root>
|
|
25
|
-
|
|
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,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.
|
|
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.
|
|
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":
|
|
8
|
-
"situationCount":
|
|
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.
|
|
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._
|