@tinytars/frame 0.1.1 → 0.1.3

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,85 @@
1
+ <script lang="ts">
2
+ import type { Attachment } from "./attachment-types";
3
+ import AttachmentViewer from "./AttachmentViewer.svelte";
4
+
5
+ // The one shared display for a leaf's attachments[]: a row of small thumbnail chips (image
6
+ // preview, or a document glyph for anything else), each opening the in-app viewer
7
+ // (AttachmentViewer.svelte) instead of a bare new-tab link. Generalizes each caller's own
8
+ // bespoke img-strip snippets, which this replaces.
9
+ //
10
+ // attachmentUrl is injected rather than imported — this package doesn't know the host app's API
11
+ // route convention, only that one exists.
12
+ interface Props {
13
+ attachments: Attachment[];
14
+ clientId?: string | null;
15
+ attachmentUrl: (clientId: string, key: string) => string;
16
+ productName: string;
17
+ onRemove?: (a: Attachment) => void;
18
+ }
19
+ let { attachments, clientId = null, attachmentUrl, productName, onRemove }: Props = $props();
20
+
21
+ let openIndex = $state<number | null>(null);
22
+
23
+ function chipTitle(a: Attachment): string {
24
+ if (a.extracted?.error) return `${a.name} — couldn't be read: ${a.extracted.error}`;
25
+ if (a.extracted?.kind) return `${a.name} — read as ${a.extracted.kind}`;
26
+ return a.name;
27
+ }
28
+ </script>
29
+
30
+ {#if attachments.length > 0 && clientId}
31
+ <div class="attachment-strip">
32
+ {#each attachments as a, i (a.key)}
33
+ <div class="attachment-chip">
34
+ <button type="button" class="attachment-link" onclick={() => (openIndex = i)} title={chipTitle(a)}>
35
+ {#if a.mediaType.startsWith("image/")}
36
+ <img src={attachmentUrl(clientId, a.key)} alt="" loading="lazy" />
37
+ {:else}
38
+ <span class="attachment-glyph" aria-hidden="true">📄</span>
39
+ {/if}
40
+ </button>
41
+ <!-- A document that was read contributes its text to whatever the assistant answers next;
42
+ one that failed contributes nothing at all. That difference is invisible without a
43
+ mark, and a silently-ignored document is exactly the failure this milestone set out
44
+ to end. -->
45
+ {#if a.extracted?.error}
46
+ <span class="attachment-badge failed" title="Couldn't read this document: {a.extracted.error}">!</span>
47
+ {:else if a.extracted && a.extracted.chars > 0}
48
+ <span class="attachment-badge read" title="Read — {a.extracted.chars.toLocaleString()} characters available to {productName}">✓</span>
49
+ {/if}
50
+ {#if onRemove}
51
+ <button type="button" class="attachment-remove" onclick={() => onRemove(a)} aria-label="Remove {a.name}">✕</button>
52
+ {/if}
53
+ </div>
54
+ {/each}
55
+ </div>
56
+ {#if openIndex !== null}
57
+ <AttachmentViewer {attachments} bind:index={openIndex} {clientId} {attachmentUrl} onClose={() => (openIndex = null)} />
58
+ {/if}
59
+ {/if}
60
+
61
+ <style>
62
+ .attachment-strip { display: flex; gap: 0.4rem; overflow-x: auto; margin-top: 0.4rem; padding-bottom: 0.1rem; }
63
+ .attachment-chip { position: relative; flex: none; }
64
+ .attachment-link {
65
+ display: flex; align-items: center; justify-content: center;
66
+ width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border);
67
+ background: var(--band); overflow: hidden; padding: 0; cursor: pointer; font: inherit;
68
+ }
69
+ .attachment-link:hover { border-color: var(--accent); }
70
+ .attachment-link img { width: 100%; height: 100%; object-fit: cover; display: block; }
71
+ .attachment-glyph { font-size: 1.4rem; }
72
+ .attachment-remove {
73
+ position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%;
74
+ border: 1px solid var(--border); background: white; color: var(--muted); font-size: 0.65rem;
75
+ line-height: 1; padding: 0; cursor: pointer; display: flex; align-items: center; justify-content: center;
76
+ }
77
+ .attachment-remove:hover { color: var(--alert); border-color: var(--alert); }
78
+ .attachment-badge {
79
+ position: absolute; bottom: -4px; left: -4px; min-width: 16px; height: 16px; border-radius: 50%;
80
+ border: 1px solid var(--border); background: var(--bg); font-size: 0.6rem; line-height: 1;
81
+ display: flex; align-items: center; justify-content: center; padding: 0 2px;
82
+ }
83
+ .attachment-badge.read { color: var(--accent); }
84
+ .attachment-badge.failed { color: var(--alert); border-color: var(--alert); }
85
+ </style>
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ import type { Attachment } from "./attachment-types";
3
+ import Modal from "./Modal.svelte";
4
+ import { openPdf, type PdfDoc } from "./pdf-render";
5
+
6
+ // The in-app viewer every attachment click opens (AttachmentStrip.svelte) instead
7
+ // of a bare new-tab link. Images render directly; PDFs render page-by-page onto a <canvas> via
8
+ // the shared lazy pdfjs loader (pdf-render.ts) so the ~500 KB pdfjs bundle only loads when a PDF
9
+ // is actually being viewed. This is the "see -> scroll pages -> Download" path an ER doctor
10
+ // needs, with no menu digging.
11
+ interface Props {
12
+ attachments: Attachment[];
13
+ index: number;
14
+ clientId: string;
15
+ attachmentUrl: (clientId: string, key: string) => string;
16
+ onClose: () => void;
17
+ }
18
+ let { attachments, index = $bindable(), clientId, attachmentUrl, onClose }: Props = $props();
19
+
20
+ let current = $derived(attachments[index]);
21
+ let isImage = $derived(current?.mediaType.startsWith("image/") ?? false);
22
+ let isPdf = $derived(current?.mediaType === "application/pdf");
23
+
24
+ function go(delta: number) {
25
+ index = Math.max(0, Math.min(attachments.length - 1, index + delta));
26
+ }
27
+ function onKeydown(e: KeyboardEvent) {
28
+ if (e.key === "ArrowLeft") go(-1);
29
+ else if (e.key === "ArrowRight") go(1);
30
+ }
31
+
32
+ // PDF page state — reset whenever the current attachment changes.
33
+ let pdfDoc = $state<PdfDoc | null>(null);
34
+ let pdfPage = $state(1);
35
+ let pdfError = $state<string | null>(null);
36
+ let canvasEl = $state<HTMLCanvasElement | undefined>();
37
+
38
+ $effect(() => {
39
+ pdfDoc = null;
40
+ pdfPage = 1;
41
+ pdfError = null;
42
+ if (!isPdf || !current) return;
43
+ const url = attachmentUrl(clientId, current.key);
44
+ openPdf(url)
45
+ .then((doc) => { pdfDoc = doc; })
46
+ .catch((e) => { pdfError = e instanceof Error ? e.message : "Couldn't open this PDF."; });
47
+ });
48
+
49
+ $effect(() => {
50
+ if (pdfDoc && canvasEl) pdfDoc.renderPage(pdfPage, canvasEl);
51
+ });
52
+ </script>
53
+
54
+ <svelte:window onkeydown={onKeydown} />
55
+
56
+ <Modal label={current?.name ?? "Attachment"} onClose={onClose} wide>
57
+ <div class="viewer">
58
+ <div class="viewer-head">
59
+ <span class="viewer-name">{current?.name}</span>
60
+ <a class="viewer-download" href={clientId && current ? attachmentUrl(clientId, current.key) : "#"} download={current?.name} target="_blank" rel="noopener">⤓ Download</a>
61
+ </div>
62
+ <div class="viewer-body">
63
+ {#if attachments.length > 1}
64
+ <button type="button" class="viewer-nav prev" disabled={index === 0} onclick={() => go(-1)} aria-label="Previous attachment">‹</button>
65
+ {/if}
66
+ {#if isImage && current}
67
+ <img class="viewer-image" src={attachmentUrl(clientId, current.key)} alt={current.name} />
68
+ {:else if isPdf}
69
+ {#if pdfError}
70
+ <p class="viewer-error">{pdfError}</p>
71
+ {:else if !pdfDoc}
72
+ <p class="viewer-loading">Loading PDF…</p>
73
+ {:else}
74
+ <canvas bind:this={canvasEl}></canvas>
75
+ {/if}
76
+ {:else}
77
+ <p class="viewer-unsupported">No preview available for this file — use Download.</p>
78
+ {/if}
79
+ {#if attachments.length > 1}
80
+ <button type="button" class="viewer-nav next" disabled={index === attachments.length - 1} onclick={() => go(1)} aria-label="Next attachment">›</button>
81
+ {/if}
82
+ </div>
83
+ {#if isPdf && pdfDoc && pdfDoc.numPages > 1}
84
+ <div class="viewer-pages">
85
+ <button type="button" class="btn" disabled={pdfPage <= 1} onclick={() => (pdfPage = Math.max(1, pdfPage - 1))}>‹ Page</button>
86
+ <span>Page {pdfPage} of {pdfDoc.numPages}</span>
87
+ <button type="button" class="btn" disabled={pdfPage >= pdfDoc.numPages} onclick={() => (pdfPage = Math.min(pdfDoc!.numPages, pdfPage + 1))}>Page ›</button>
88
+ </div>
89
+ {/if}
90
+ {#if attachments.length > 1}
91
+ <p class="viewer-count">{index + 1} of {attachments.length}</p>
92
+ {/if}
93
+ </div>
94
+ </Modal>
95
+
96
+ <style>
97
+ .viewer { display: flex; flex-direction: column; gap: 0.75rem; }
98
+ .viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-right: 1.5rem; }
99
+ .viewer-name { font-weight: 600; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
100
+ .viewer-download { font-size: 0.85rem; color: var(--accent); white-space: nowrap; }
101
+ .viewer-body { position: relative; display: flex; align-items: center; justify-content: center; min-height: 300px; }
102
+ .viewer-image { max-width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px; }
103
+ .viewer-body canvas { max-width: 100%; max-height: 70vh; border: 1px solid var(--border); border-radius: 4px; }
104
+ .viewer-loading, .viewer-error, .viewer-unsupported { color: var(--muted); font-size: 0.9rem; }
105
+ .viewer-error { color: var(--alert); }
106
+ .viewer-nav {
107
+ position: absolute; top: 50%; transform: translateY(-50%); z-index: 1;
108
+ width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--border); background: white;
109
+ font-size: 1.3rem; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center;
110
+ }
111
+ .viewer-nav:disabled { opacity: 0.3; cursor: not-allowed; }
112
+ .viewer-nav.prev { left: -0.5rem; }
113
+ .viewer-nav.next { right: -0.5rem; }
114
+ .viewer-pages { display: flex; align-items: center; justify-content: center; gap: 0.75rem; font-size: 0.85rem; }
115
+ .viewer-count { text-align: center; color: var(--muted); font-size: 0.8rem; margin: 0; }
116
+ </style>
package/Button.svelte ADDED
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ // A hoisted ".btn"/".btn.primary" rule, now the component itself rather than a class-name
5
+ // convention. `class` carries a caller's own local variant (`.done`, `.del`, …) alongside the
6
+ // base classes this renders — those variants' rule bodies stay local to the caller, targeted
7
+ // with `:global()` since the element now lives in this component's scope, not the caller's.
8
+ interface Props {
9
+ class?: string;
10
+ primary?: boolean;
11
+ disabled?: boolean;
12
+ type?: "button" | "submit" | "reset";
13
+ onclick?: (e: MouseEvent) => void;
14
+ children: Snippet;
15
+ [key: string]: unknown;
16
+ }
17
+ let { class: extraClass = "", primary = false, disabled = false, type = "button", onclick, children, ...rest }: Props = $props();
18
+ </script>
19
+
20
+ <button {type} class="btn {extraClass}" class:primary {disabled} {onclick} {...rest}>{@render children()}</button>
21
+
22
+ <style>
23
+ .btn { font: inherit; font-size: 0.9rem; border-radius: 6px; cursor: pointer; padding: 0.45rem 0.9rem; border: 1px solid var(--border); background: white; color: var(--fg); }
24
+ .btn.primary { border-color: var(--accent); background: var(--accent); color: white; }
25
+ .btn.primary:disabled { opacity: 0.5; cursor: default; }
26
+ @media (max-width: 640px) { .btn { min-height: 44px; } }
27
+ </style>
@@ -0,0 +1,124 @@
1
+ <script lang="ts">
2
+ import { claimDictation, releaseDictation } from "./dictate-registry";
3
+
4
+ // SpeechRecognition (unlike SpeechSynthesis) isn't in TypeScript's own DOM lib — it's a
5
+ // non-standardized, Chromium/Safari-only API (Firefox has neither `SpeechRecognition` nor
6
+ // `webkitSpeechRecognition` at all). Minimal ambient shape for just what this component uses.
7
+ interface SpeechRecognitionResult { 0: { transcript: string }; isFinal: boolean }
8
+ interface SpeechRecognitionEvent extends Event { results: ArrayLike<SpeechRecognitionResult>; resultIndex: number }
9
+ interface SpeechRecognitionLike extends EventTarget {
10
+ continuous: boolean;
11
+ interimResults: boolean;
12
+ lang: string;
13
+ onresult: ((e: SpeechRecognitionEvent) => void) | null;
14
+ onerror: ((e: Event) => void) | null;
15
+ onend: (() => void) | null;
16
+ start(): void;
17
+ stop(): void;
18
+ }
19
+ type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
20
+
21
+ function recognitionCtor(): SpeechRecognitionCtor | undefined {
22
+ if (typeof window === "undefined") return undefined;
23
+ const w = window as unknown as { SpeechRecognition?: SpeechRecognitionCtor; webkitSpeechRecognition?: SpeechRecognitionCtor };
24
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
25
+ }
26
+ const supported = !!recognitionCtor();
27
+
28
+ // Dictation appends rather than replaces: never silently destroys something the user already
29
+ // typed. onResult fires once, with the accumulated final transcript, when the session ends
30
+ // (manual stop or the browser's own silence-timeout onend).
31
+ interface Props {
32
+ onResult: (text: string) => void;
33
+ lang?: string;
34
+ title?: string;
35
+ }
36
+ let { onResult, lang, title = "Dictate" }: Props = $props();
37
+
38
+ let listening = $state(false);
39
+ let error = $state<string | null>(null);
40
+ let recognition: SpeechRecognitionLike | null = null;
41
+ let finalText = "";
42
+
43
+ function stop() {
44
+ recognition?.stop();
45
+ }
46
+
47
+ function start() {
48
+ const Ctor = recognitionCtor();
49
+ if (!Ctor) return;
50
+ error = null;
51
+ finalText = "";
52
+ const r = new Ctor();
53
+ r.continuous = true;
54
+ r.interimResults = true;
55
+ if (lang) r.lang = lang;
56
+ r.onresult = (e) => {
57
+ for (let i = e.resultIndex; i < e.results.length; i++) {
58
+ const result = e.results[i];
59
+ if (result.isFinal) finalText = finalText ? `${finalText} ${result[0].transcript}` : result[0].transcript;
60
+ }
61
+ };
62
+ r.onerror = () => {
63
+ // Permission-denied or no-speech-detected both land here — surface a brief inline message
64
+ // rather than throwing; the session is already over by the time onerror fires.
65
+ error = "Couldn't hear you — check mic permission and try again.";
66
+ };
67
+ r.onend = () => {
68
+ listening = false;
69
+ releaseDictation(stop);
70
+ recognition = null;
71
+ if (finalText.trim()) onResult(finalText.trim());
72
+ };
73
+ recognition = r;
74
+ try {
75
+ claimDictation(stop);
76
+ r.start();
77
+ listening = true;
78
+ } catch {
79
+ // .start() throws synchronously if called while another recognition on the SAME
80
+ // instance is already active, or the browser denies it outright; fail soft, no crash.
81
+ error = "Dictation isn't available right now.";
82
+ listening = false;
83
+ releaseDictation(stop);
84
+ recognition = null;
85
+ }
86
+ }
87
+
88
+ function toggle() {
89
+ if (listening) stop();
90
+ else start();
91
+ }
92
+ </script>
93
+
94
+ {#if supported}
95
+ <button
96
+ type="button"
97
+ class="dictate-btn"
98
+ class:listening
99
+ {title}
100
+ aria-label={title}
101
+ aria-pressed={listening}
102
+ onmousedown={(e) => e.preventDefault()}
103
+ onclick={toggle}
104
+ >{listening ? "🔴" : "🎤"}</button>
105
+ {#if error}<span class="dictate-error">{error}</span>{/if}
106
+ {/if}
107
+
108
+ <style>
109
+ .dictate-btn {
110
+ flex-shrink: 0; border: none; background: none; cursor: pointer;
111
+ font-size: 1rem; line-height: 1; padding: 0.35rem; border-radius: 6px;
112
+ color: var(--muted);
113
+ }
114
+ .dictate-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
115
+ .dictate-btn.listening {
116
+ color: var(--alert);
117
+ animation: dictate-pulse 1.2s ease-in-out infinite;
118
+ }
119
+ @keyframes dictate-pulse {
120
+ 0%, 100% { opacity: 1; }
121
+ 50% { opacity: 0.45; }
122
+ }
123
+ .dictate-error { font-size: 0.72rem; color: var(--alert); margin-left: 0.3rem; }
124
+ </style>
package/Field.svelte ADDED
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ // Replaces the ".field" label+span wrapper hoisted across a caller's own Add/Edit modals.
5
+ // `wide` fills its own grid row (`.field--wide`); `span` spans the whole grid (`.field--span`) —
6
+ // the two collided under one shared name for one caller and stay distinct props here for the
7
+ // same reason.
8
+ //
9
+ // Styled with :global() rather than Svelte's normal component-scoped CSS: a caller may also
10
+ // render a bare `class="field"` span directly in its own templates (a read-only display column
11
+ // reusing the same layout, not a labeled input) rather than through this component, and still
12
+ // need the identical base rules.
13
+ interface Props {
14
+ label: string;
15
+ wide?: boolean;
16
+ span?: boolean;
17
+ children: Snippet;
18
+ }
19
+ let { label, wide = false, span = false, children }: Props = $props();
20
+ </script>
21
+
22
+ <label class="field" class:field--wide={wide} class:field--span={span}>
23
+ <span>{label}</span>
24
+ {@render children()}
25
+ </label>
26
+
27
+ <style>
28
+ :global(.field) { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
29
+ :global(.field--wide) { width: 100%; }
30
+ :global(.field--span) { grid-column: 1 / -1; }
31
+ /* Uppercase caption above a field. Excludes a caller's own permalink-heading inner span so a
32
+ read-only .field usage doesn't uppercase-transform the actual value it wraps there. */
33
+ :global(.field > span:not(.permalink-heading)) {
34
+ font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
35
+ }
36
+ </style>
@@ -0,0 +1,15 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ // The field grid a caller's Add/Edit modals and search previews share — a plain wrapping
5
+ // div, so component-scoped CSS is enough: every caller renders this through the component rather
6
+ // than reusing the bare class name elsewhere.
7
+ interface Props { children: Snippet }
8
+ let { children }: Props = $props();
9
+ </script>
10
+
11
+ <div class="form-grid">{@render children()}</div>
12
+
13
+ <style>
14
+ .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.9rem 1.1rem; }
15
+ </style>
package/Modal.svelte ADDED
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ // Generic modal overlay (backdrop + close), built on the native <dialog> element, which
3
+ // supplies as BROWSER PRIMITIVES what a hand-rolled overlay previously did not have at all: a
4
+ // focus trap, focus restore on close, an inert background, Escape handling, and top-layer
5
+ // stacking. Hand-rolling a focus trap is ~80 lines of the most-often-wrong code in front-end
6
+ // and still yields neither top-layer nor `inert`.
7
+ //
8
+ // What was broken with a hand-rolled overlay, across every call site:
9
+ // • NOTHING ever focused the panel. `tabindex="-1"` + `aria-modal="true"` told a screen reader the
10
+ // background was inert while a keyboard user could still Tab straight into it.
11
+ // • `<svelte:window onkeydown>` meant Escape closed EVERY mounted Modal at once, not the top one.
12
+ // • The backdrop closed on any click, with no dirty check — so a patient typing a long note lost
13
+ // all of it to one mis-aimed click, and to a text-selection drag released outside the panel.
14
+ //
15
+ // The API is deliberately unchanged for callers that have no unsaved state: `label` / `onClose` /
16
+ // `wide` / `children` behave exactly as before, so most call sites change zero characters.
17
+ import type { Snippet } from "svelte";
18
+ interface Props {
19
+ label: string;
20
+ onClose: () => void;
21
+ wide?: boolean;
22
+ /**
23
+ * The form state to watch, as a getter — e.g. `() => newNote`.
24
+ *
25
+ * Modal compares it against a snapshot taken WHEN THIS COMPONENT MOUNTS, which is exactly when the
26
+ * modal opens (every call site guards it with `{#if addOpen && draft}`). Using the component's own
27
+ * lifecycle is why there is no `$effect` and no separate tracker module here: the first design had
28
+ * both, to observe an "is it open" transition the mount already is.
29
+ *
30
+ * Omitted means "never dirty" — right for the read-only hosts (About, attachment viewer, import).
31
+ */
32
+ snapshot?: () => unknown;
33
+ discardMessage?: string;
34
+ children: Snippet;
35
+ }
36
+ let {
37
+ label,
38
+ onClose,
39
+ wide = false,
40
+ snapshot = undefined,
41
+ discardMessage = "Discard your unsaved changes?",
42
+ children,
43
+ }: Props = $props();
44
+
45
+ // Captured once, at mount. Serialisable form state only (strings, numbers, small arrays), so a JSON
46
+ // compare is exact for the values that matter. Being wrong in the SAFE direction — asking when
47
+ // nothing really changed — costs one confirmation; the opposite silently discards a patient's work.
48
+ const pristine = snapshot ? JSON.stringify(snapshot()) : null;
49
+ const isDirty = (): boolean => pristine !== null && JSON.stringify(snapshot!()) !== pristine;
50
+
51
+ let dialog = $state<HTMLDialogElement | null>(null);
52
+ let confirmEl = $state<HTMLDialogElement | null>(null);
53
+ // Where a backdrop press STARTED. A text-selection drag that begins inside the panel and releases
54
+ // on the backdrop dispatches `click` at the backdrop — which used to close the modal and discard
55
+ // everything typed. Requiring both press and release on the backdrop fixes that.
56
+ let pressedOnBackdrop = false;
57
+
58
+ // showModal() rather than the `open` attribute: only the former puts the dialog in the top layer,
59
+ // makes the rest of the document inert, traps focus and restores it on close.
60
+ $effect(() => {
61
+ dialog?.showModal();
62
+ });
63
+
64
+ function requestClose(): void {
65
+ if (isDirty()) confirmEl?.showModal();
66
+ else onClose();
67
+ }
68
+
69
+ /** Escape (and any other user-agent dismiss) arrives as a cancelable `cancel` event — per dialog. */
70
+ function onCancel(e: Event): void {
71
+ e.preventDefault(); // never let the browser close it out from under an unsaved edit
72
+ requestClose();
73
+ }
74
+ </script>
75
+
76
+ <dialog
77
+ bind:this={dialog}
78
+ class="modal-panel"
79
+ class:wide
80
+ aria-label={label}
81
+ oncancel={onCancel}
82
+ onmousedown={(e) => (pressedOnBackdrop = e.target === dialog)}
83
+ onclick={(e) => {
84
+ if (e.target === dialog && pressedOnBackdrop) requestClose();
85
+ pressedOnBackdrop = false;
86
+ }}
87
+ >
88
+ <div class="modal-inner">
89
+ <button class="modal-close" aria-label="Close" onclick={requestClose}>×</button>
90
+ {@render children()}
91
+ </div>
92
+ </dialog>
93
+
94
+ <!-- Defined once here, not at 15 call sites. A nested <dialog> works because the top layer stacks;
95
+ window.confirm() would block the event loop, cannot be styled, and is invisible to Playwright
96
+ without a dialog handler. -->
97
+ <dialog bind:this={confirmEl} class="discard-confirm" aria-label="Discard changes?">
98
+ <p>{discardMessage}</p>
99
+ <div class="discard-actions">
100
+ <button class="btn" onclick={() => confirmEl?.close()}>Keep editing</button>
101
+ <button
102
+ class="btn danger"
103
+ onclick={() => {
104
+ confirmEl?.close();
105
+ onClose();
106
+ }}>Discard</button
107
+ >
108
+ </div>
109
+ </dialog>
110
+
111
+ <style>
112
+ /* The element IS the panel now; ::backdrop replaces the wrapper div the browser used to need. */
113
+ .modal-panel {
114
+ position: fixed;
115
+ inset: 0;
116
+ margin: 4rem auto auto;
117
+ background: white;
118
+ color: inherit;
119
+ max-width: 46rem;
120
+ width: calc(100% - 2rem);
121
+ max-height: calc(100vh - 8rem);
122
+ overflow-y: auto;
123
+ border: none;
124
+ border-radius: 12px;
125
+ padding: 0;
126
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
127
+ }
128
+ .modal-panel::backdrop { background: rgba(0, 0, 0, 0.4); }
129
+ .modal-panel.wide { max-width: min(70rem, 95vw); }
130
+ /* Padding lives on an inner wrapper so a backdrop click is unambiguously outside the content: on
131
+ the dialog itself, padding is part of the element's box and would swallow near-edge presses. */
132
+ .modal-inner { position: relative; padding: 1.75rem 2rem 2rem; }
133
+ .modal-close {
134
+ position: absolute;
135
+ top: 0.6rem;
136
+ right: 0.8rem;
137
+ border: none;
138
+ background: none;
139
+ font-size: 1.6rem;
140
+ line-height: 1;
141
+ color: var(--muted);
142
+ cursor: pointer;
143
+ padding: 0.2rem 0.4rem;
144
+ }
145
+ .modal-close:hover { color: var(--fg); }
146
+
147
+ .discard-confirm {
148
+ border: none;
149
+ border-radius: 10px;
150
+ padding: 1.25rem 1.5rem;
151
+ max-width: 24rem;
152
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
153
+ }
154
+ .discard-confirm::backdrop { background: rgba(0, 0, 0, 0.3); }
155
+ .discard-confirm p { margin: 0 0 1rem; }
156
+ .discard-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
157
+
158
+ /* The desktop margin is dead space on a short phone viewport. */
159
+ @media (max-width: 640px) {
160
+ .modal-panel { margin-top: 1rem; max-height: calc(100vh - 2rem); width: calc(100% - 1rem); }
161
+ .modal-inner { padding: 1.25rem 1rem 1.5rem; }
162
+ }
163
+ @media print { .modal-panel { display: none !important; } }
164
+ </style>
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ // Slot wrapper for the Cancel/Save button row hoisted across every leaf editor's Add/Edit modal.
5
+ interface Props { children: Snippet }
6
+ let { children }: Props = $props();
7
+ </script>
8
+
9
+ <div class="modal-actions">{@render children()}</div>
10
+
11
+ <style>
12
+ .modal-actions { display: flex; align-items: center; justify-content: flex-end; gap: 0.4rem; }
13
+ </style>
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import { openPdf } from "./pdf-render";
3
+
4
+ // A report's first-page thumbnail on a reports list — the ER-doctor path: see the first page ->
5
+ // click to scroll through the rest -> Download, no menu digging. Loads pdfjs (via the shared
6
+ // pdf-render.ts) only once the card actually scrolls into view (IntersectionObserver), so a tab
7
+ // with many reports doesn't pay pdfjs's ~500 KB cost until a thumbnail is actually about to
8
+ // render.
9
+ interface Props {
10
+ url: string;
11
+ onOpen?: () => void;
12
+ }
13
+ let { url, onOpen }: Props = $props();
14
+
15
+ let rootEl: HTMLDivElement | undefined;
16
+ let canvasEl = $state<HTMLCanvasElement | undefined>();
17
+ let thumbState = $state<"pending" | "loading" | "ready" | "error">("pending");
18
+
19
+ $effect(() => {
20
+ if (!rootEl) return;
21
+ const obs = new IntersectionObserver((entries) => {
22
+ if (entries.some((e) => e.isIntersecting)) {
23
+ obs.disconnect();
24
+ load();
25
+ }
26
+ }, { rootMargin: "200px" });
27
+ obs.observe(rootEl);
28
+ return () => obs.disconnect();
29
+ });
30
+
31
+ async function load() {
32
+ thumbState = "loading";
33
+ try {
34
+ const doc = await openPdf(url);
35
+ thumbState = "ready";
36
+ // canvasEl only exists once thumbState flips to "ready" and Svelte re-renders; await a tick via
37
+ // requestAnimationFrame so bind:this has landed before we render into it.
38
+ requestAnimationFrame(async () => {
39
+ if (canvasEl) await doc.renderPage(1, canvasEl, 240);
40
+ });
41
+ } catch {
42
+ thumbState = "error";
43
+ }
44
+ }
45
+ </script>
46
+
47
+ <div class="pdf-thumb" bind:this={rootEl}>
48
+ <button type="button" class="pdf-thumb-btn" onclick={() => onOpen?.()} aria-label="Preview">
49
+ {#if thumbState === "ready"}
50
+ <canvas bind:this={canvasEl}></canvas>
51
+ {:else if thumbState === "error"}
52
+ <span class="pdf-thumb-glyph" aria-hidden="true">📄</span>
53
+ {:else}
54
+ <span class="pdf-thumb-glyph pdf-thumb-pending" aria-hidden="true">📄</span>
55
+ {/if}
56
+ </button>
57
+ </div>
58
+
59
+ <style>
60
+ .pdf-thumb { flex: none; }
61
+ .pdf-thumb-btn {
62
+ display: flex; align-items: center; justify-content: center;
63
+ width: 64px; height: 84px; border-radius: 6px; border: 1px solid var(--border);
64
+ background: var(--band); padding: 0; cursor: pointer; overflow: hidden;
65
+ }
66
+ .pdf-thumb-btn:hover { border-color: var(--accent); }
67
+ .pdf-thumb-btn canvas { max-width: 100%; max-height: 100%; }
68
+ .pdf-thumb-glyph { font-size: 1.6rem; }
69
+ .pdf-thumb-pending { opacity: 0.5; }
70
+ </style>