@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.
package/pdf-render.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Lazy pdfjs-dist loader, shared by AttachmentViewer.svelte (the full in-app PDF viewer) and
2
+ // PdfThumbnail.svelte (a reports list's first-page preview). pdfjs-dist (~500 KB) is deliberately
3
+ // kept out of the main render bundle; this dynamic import is the only place in the rendering path
4
+ // that pulls it in, and only once a PDF actually needs to be shown as pixels.
5
+ let pdfjsPromise: Promise<typeof import("pdfjs-dist/legacy/build/pdf.mjs")> | null = null;
6
+
7
+ async function loadPdfjs() {
8
+ if (!pdfjsPromise) {
9
+ pdfjsPromise = import("pdfjs-dist/legacy/build/pdf.mjs").then(async (pdfjs) => {
10
+ const workerUrl = (await import("pdfjs-dist/legacy/build/pdf.worker.mjs?url")).default;
11
+ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
12
+ return pdfjs;
13
+ });
14
+ }
15
+ return pdfjsPromise;
16
+ }
17
+
18
+ export interface PdfDoc {
19
+ numPages: number;
20
+ renderPage(pageNum: number, canvas: HTMLCanvasElement, maxWidth?: number): Promise<void>;
21
+ }
22
+
23
+ // `source` is either a URL (fetched by the caller's own API route) or raw bytes.
24
+ export async function openPdf(source: string | Uint8Array): Promise<PdfDoc> {
25
+ const pdfjs = await loadPdfjs();
26
+ const params = typeof source === "string" ? { url: source } : { data: source };
27
+ // isEvalSupported:false — same runtime option parsers/report.ts and parsers/dexa.ts already set
28
+ // (missing from the legacy build's DocumentInitParameters typing there too). The url|data union
29
+ // here doesn't structurally match DocumentInitParameters closely enough for a direct `as`, hence
30
+ // the double cast via unknown (same reason TS gives for suggesting it).
31
+ const doc = await pdfjs.getDocument(
32
+ { ...params, isEvalSupported: false } as unknown as Parameters<typeof pdfjs.getDocument>[0],
33
+ ).promise;
34
+ return {
35
+ numPages: doc.numPages,
36
+ async renderPage(pageNum: number, canvas: HTMLCanvasElement, maxWidth = 900) {
37
+ const page = await doc.getPage(pageNum);
38
+ const unscaled = page.getViewport({ scale: 1 });
39
+ const scale = maxWidth / unscaled.width;
40
+ const viewport = page.getViewport({ scale });
41
+ canvas.width = viewport.width;
42
+ canvas.height = viewport.height;
43
+ const ctx = canvas.getContext("2d");
44
+ if (!ctx) return;
45
+ await page.render({ canvasContext: ctx, canvas, viewport }).promise;
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,17 @@
1
+ // Generic localStorage JSON persistence primitive. One place to get "load with a fallback, save
2
+ // silently no-op'ing outside the browser" right, instead of each caller hand-rolling its own pair.
3
+ export function loadJSON<T>(key: string, fallback: T): T {
4
+ if (typeof localStorage === "undefined") return fallback;
5
+ const raw = localStorage.getItem(key);
6
+ if (raw === null) return fallback;
7
+ try {
8
+ return JSON.parse(raw) as T;
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+
14
+ export function saveJSON<T>(key: string, value: T): void {
15
+ if (typeof localStorage === "undefined") return;
16
+ localStorage.setItem(key, JSON.stringify(value));
17
+ }
@@ -0,0 +1,47 @@
1
+ // The two row SHAPES the sidebar renders, as a plain .ts module.
2
+ //
3
+ // A type exported from a .svelte file can only be resolved by svelte-check, never by plain `tsc`.
4
+ // These two were declared inside SidebarLeafList.svelte / SidebarGroupList.svelte and imported by
5
+ // pure modules that build sidebar rows, which put those modules — and any Node-side script that
6
+ // imports them — beyond the reach of any Node-side type check. The components keep the rendering;
7
+ // the shape lives here.
8
+
9
+ export interface SidebarLeafRow {
10
+ key: string;
11
+ label: string;
12
+ anchor: string;
13
+ pinned?: boolean;
14
+ // The vault record this row stands for, when the row's own `key` is not that id — Hypothesis
15
+ // keys its rows positionally (topic+side+index, because that is what its anchor needs) while
16
+ // the record behind a patient idea is a DecisionEntry. `undefined` means "key IS the id";
17
+ // `null` means "this row has no record at all" (an AI-proposed idea), so it cannot be pinned.
18
+ itemId?: string | null;
19
+ // Falls back to `label` when absent — lets a truncated label (e.g. Notes' preview text) still
20
+ // filter against the full underlying text.
21
+ searchText?: string;
22
+ // An id to put on the row element itself. Only set where the SIDEBAR row is the only place that
23
+ // item exists (Chat's threads) — everywhere else the main panel already owns the anchor id, and
24
+ // a duplicate would capture getElementById and break scroll-to.
25
+ domId?: string;
26
+ }
27
+
28
+ export interface SidebarGroupRow {
29
+ key: string;
30
+ label: string;
31
+ count?: number;
32
+ title?: string;
33
+ // Optional per-row quick-add, for group rows that are really navigation targets in disguise
34
+ // rather than an in-page filter. Same class/aria-label convention as the top-level row's own
35
+ // `.side-row-action` so a caller's existing selectors keyed on that convention keep resolving.
36
+ // Trails the label rather than leading it, so the label itself sits flush left with no reserved
37
+ // gutter.
38
+ action?: { label: string; onClick: () => void };
39
+ // This group's individual items, already anchored (the same anchors a caller's search feature
40
+ // would scroll to) — shown nested/indented when the row's chevron is expanded. Absent (not
41
+ // empty) means "no chevron for this row" — real navigation, not a filter group with members to
42
+ // list.
43
+ children?: SidebarLeafRow[];
44
+ // A row can opt into starting expanded rather than collapsed, e.g. a section's sole "Ungrouped"
45
+ // row that should stay always-visible.
46
+ defaultExpanded?: boolean;
47
+ }
@@ -0,0 +1,50 @@
1
+ // The one shared "who's currently speaking" registry, mirroring menu-registry.svelte.ts's
2
+ // singleton shape: starting a new utterance stops whatever else was speaking, and clicking the
3
+ // currently-speaking bubble's own button again stops it (toggle) — the same "only one thing
4
+ // active" convention a popover-heavy UI wants for this too.
5
+ //
6
+ // speechSynthesis is a real, global, OS-level engine — it can finish an utterance (or fail) entirely
7
+ // on its own, not just via a manual stop() call. `onend`/`onerror` on the utterance are what keep
8
+ // `speakingId` in sync with reality in that case; a plain isSpeaking() flag flipped only at the two
9
+ // call sites (speak/stop) would go stale the instant a normal utterance finishes.
10
+ const state = $state<{ speakingId: string | null }>({ speakingId: null });
11
+
12
+ function synth(): SpeechSynthesis | undefined {
13
+ return typeof window !== "undefined" ? window.speechSynthesis : undefined;
14
+ }
15
+
16
+ export function isSpeechSupported(): boolean {
17
+ return !!synth();
18
+ }
19
+
20
+ export const speechRegistry = {
21
+ isSpeaking(id: string): boolean {
22
+ return state.speakingId === id;
23
+ },
24
+ speak(id: string, text: string): void {
25
+ const s = synth();
26
+ if (!s || !text.trim()) return;
27
+ // cancel()'s `end` event is dispatched as its own task, never inside this call — which is what
28
+ // makes the toggle below reachable, since it compares against a speakingId this cancel has not
29
+ // had the chance to clear. The old speakingId is cleared by the assignment further down (or by
30
+ // the toggle); each handler guards on its own id so a late `end` cannot clear a newer utterance.
31
+ // (An earlier version of this comment claimed the opposite, that cancel() fires onend
32
+ // SYNCHRONOUSLY and that this is what clears the old id. Were that true the toggle would be
33
+ // dead code and a second click would re-speak.)
34
+ s.cancel();
35
+ if (state.speakingId === id) {
36
+ // Toggle: clicking the already-speaking bubble's own button again just stops it.
37
+ state.speakingId = null;
38
+ return;
39
+ }
40
+ const utterance = new SpeechSynthesisUtterance(text);
41
+ utterance.onend = () => { if (state.speakingId === id) state.speakingId = null; };
42
+ utterance.onerror = () => { if (state.speakingId === id) state.speakingId = null; };
43
+ state.speakingId = id;
44
+ s.speak(utterance);
45
+ },
46
+ stop(): void {
47
+ synth()?.cancel();
48
+ state.speakingId = null;
49
+ },
50
+ };
package/theme.css ADDED
@@ -0,0 +1,94 @@
1
+ /* Shell + persona theme: the design tokens and cross-cutting CSS every app built on frame needs,
2
+ not tied to any one component. Import once (e.g. from the host app's entry point) alongside the
3
+ app's own stylesheet. Persona tokens name provenance generically — assistant/owner/provider —
4
+ so a non-health app can reuse the same three-way attribution bubble without health vocabulary. */
5
+ :root {
6
+ --bg: #fafaf9;
7
+ --fg: #1a1a1a;
8
+ --muted: #6b7280;
9
+ --border: #e5e7eb;
10
+ --accent: #0b6bcb;
11
+ /* Neutral surfaces distinct from --bg, used for cards/panels raised above the page. */
12
+ --surface: #fff;
13
+ --surface-2: #f4f4f5;
14
+ --panel: #fbfcfd;
15
+ --code-bg: #f4f6f8;
16
+ --tt-footer-accent: var(--accent);
17
+ --band: rgba(11, 107, 203, 0.08);
18
+ --safe: #16a34a;
19
+ --safe-band: rgba(22, 163, 74, 0.10);
20
+ --warn: #d97706;
21
+ --warn-band: rgba(217, 119, 6, 0.10);
22
+ --alert: #dc2626;
23
+ --alert-band: rgba(220, 38, 38, 0.10);
24
+ /* "Generated" content — extracted from a source or produced by the AI. */
25
+ --gen: #7c5cbf;
26
+ --gen-band: rgba(124, 92, 191, 0.10);
27
+ --content-max: 76rem;
28
+ --leaf-max: 44rem;
29
+ /* Persona provenance: who produced a piece of content. */
30
+ --p-assistant: var(--gen);
31
+ --p-assistant-band: var(--gen-band);
32
+ --p-owner: #2f6f4f;
33
+ --p-owner-band: rgba(47, 111, 79, 0.10);
34
+ --p-provider: #64748b;
35
+ --p-provider-band: rgba(100, 116, 139, 0.12);
36
+ }
37
+
38
+ /* The standard visually-hidden recipe: removed from view but kept in the accessibility tree.
39
+ display:none and visibility:hidden would remove it from BOTH, which defeats the purpose. */
40
+ .sr-only {
41
+ position: absolute;
42
+ width: 1px; height: 1px;
43
+ padding: 0; margin: -1px;
44
+ overflow: hidden;
45
+ clip-path: inset(50%);
46
+ white-space: nowrap;
47
+ border: 0;
48
+ }
49
+
50
+ /* Shared shell so the footer/disclaimer render on every state (lock/roster/app); sticky footer. */
51
+ .app-shell { display: flex; flex-direction: column; min-height: 100dvh; }
52
+ .app-body { flex: 1 0 auto; }
53
+
54
+ /* Transient highlight applied when a permalink resolves, so the target is obvious after the
55
+ scroll. Plain (not scoped) CSS: it lands on arbitrary anchored elements anywhere. */
56
+ .permalink-flash {
57
+ animation: permalink-flash 1.2s ease-out;
58
+ border-radius: 6px;
59
+ }
60
+ @keyframes permalink-flash {
61
+ 0% { box-shadow: 0 0 0 3px var(--accent); background: var(--band); }
62
+ 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 0%, transparent); background: transparent; }
63
+ }
64
+
65
+ /* Shared field+action row: every field's own label stays a column (label text above, control
66
+ below); this wraps just the control + a side action (e.g. a mic button) side by side. */
67
+ .field-row { display: flex; align-items: flex-start; gap: 0.35rem; }
68
+ .field-row input, .field-row textarea { flex: 1; min-width: 0; }
69
+
70
+ /* The section shell every top-level view repeats, several combined with a dynamic domain class.
71
+ Plain class, not a component: usage is too dynamic to earn a prop API and moving just the CSS
72
+ already gets the shared-source-of-truth goal. */
73
+ .leaf-section { max-width: var(--leaf-max); margin: 0 auto; }
74
+
75
+ /* Same class name, same body, wherever a leaf editor takes a single free-text topic line. */
76
+ .topic-input { font: inherit; font-size: 0.95rem; color: var(--fg); padding: 0.5rem 0.65rem; border: 1px solid var(--border); border-radius: 6px; background: white; width: 100%; box-sizing: border-box; }
77
+
78
+ /* Shared persona bubble: a tinted box with a head row (uppercase tag on the left, an optional
79
+ meta/date + action on the right). PersonaBubble.svelte is the sole production emitter. */
80
+ .persona-bubble { border: 1px solid var(--border); border-radius: 12px; padding: 0.55rem 0.7rem; background: var(--surface); }
81
+ .persona-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.4rem; }
82
+ .persona-head-left { display: inline-flex; align-items: center; gap: 0.35rem; }
83
+ .persona-head-right { display: inline-flex; align-items: center; gap: 0.4rem; }
84
+ .persona-tag {
85
+ display: inline-block; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em;
86
+ border: 1px solid; border-radius: 999px; padding: 0.05rem 0.45rem; background: var(--surface);
87
+ }
88
+ .persona-meta { font-size: 0.72rem; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
89
+ .p-assistant { background: var(--p-assistant-band); border-color: var(--p-assistant); }
90
+ .p-assistant .persona-tag { color: var(--p-assistant); border-color: var(--p-assistant); }
91
+ .p-owner { background: var(--p-owner-band); border-color: var(--p-owner); }
92
+ .p-owner .persona-tag { color: var(--p-owner); border-color: var(--p-owner); }
93
+ .p-provider { background: var(--p-provider-band); border-color: var(--p-provider); }
94
+ .p-provider .persona-tag { color: var(--p-provider); border-color: var(--p-provider); }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { timeAgo } from "./time-ago";
3
+
4
+ describe("timeAgo", () => {
5
+ const NOW = new Date("2026-07-05T12:00:00Z").getTime();
6
+ const ago = (ms: number) => new Date(NOW - ms).toISOString();
7
+ const s = 1000, m = 60 * s, h = 60 * m, d = 24 * h;
8
+
9
+ it("collapses the last few seconds to 'just now'", () => {
10
+ expect(timeAgo(ago(5 * s), NOW)).toBe("just now");
11
+ expect(timeAgo(ago(44 * s), NOW)).toBe("just now");
12
+ });
13
+
14
+ it("reports minutes, hours, and days with correct pluralization", () => {
15
+ expect(timeAgo(ago(1 * m), NOW)).toBe("1 minute ago");
16
+ expect(timeAgo(ago(5 * m), NOW)).toBe("5 minutes ago");
17
+ expect(timeAgo(ago(1 * h), NOW)).toBe("1 hour ago");
18
+ expect(timeAgo(ago(3 * h), NOW)).toBe("3 hours ago");
19
+ expect(timeAgo(ago(1 * d), NOW)).toBe("1 day ago");
20
+ expect(timeAgo(ago(2 * d), NOW)).toBe("2 days ago");
21
+ });
22
+
23
+ it("rolls up to months and years for older timestamps", () => {
24
+ expect(timeAgo(ago(45 * d), NOW)).toBe("2 months ago");
25
+ expect(timeAgo(ago(400 * d), NOW)).toBe("1 year ago");
26
+ });
27
+
28
+ it("degrades a future or malformed timestamp instead of throwing", () => {
29
+ expect(timeAgo(ago(-1 * h), NOW)).toBe("just now"); // clock skew → future
30
+ expect(timeAgo("not-a-date", NOW)).toBe("unknown");
31
+ });
32
+ });
package/time-ago.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Relative "N hours/days ago" formatting. Pure: the caller passes `now` (ms) so it's
2
+ // deterministic and unit-testable. A future/malformed timestamp degrades to "just now"/"unknown".
3
+ export function timeAgo(iso: string, now: number): string {
4
+ const then = new Date(iso).getTime();
5
+ if (!Number.isFinite(then)) return "unknown";
6
+ const sec = Math.round((now - then) / 1000);
7
+ if (sec < 45) return "just now";
8
+ const min = Math.round(sec / 60);
9
+ if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
10
+ const hr = Math.round(min / 60);
11
+ if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
12
+ const day = Math.round(hr / 24);
13
+ if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
14
+ const mon = Math.round(day / 30);
15
+ if (mon < 12) return `${mon} month${mon === 1 ? "" : "s"} ago`;
16
+ const yr = Math.round(mon / 12);
17
+ return `${yr} year${yr === 1 ? "" : "s"} ago`;
18
+ }