@tinytars/frame 0.1.0

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,210 @@
1
+ <script lang="ts">
2
+ // Top-right account pulldown for a signed-in owner (self-service, !providerSession).
3
+ // Collapses the former Account / Access / Sign out header buttons into one menu; the items
4
+ // just open the existing Account/Access modals (email, sign-in methods incl. passkey/Google,
5
+ // recovery) and call signOut — no new logic lives here.
6
+ // providerAccess mode: a clinician drilled into a patient. The trigger/label shows the
7
+ // PATIENT's identity with a "Provider access" badge; the menu only offers Back to roster + Sign
8
+ // out (provider). Account/Access settings are the provider's own — they live on the roster page.
9
+ import { menuRegistry } from "@tinytars/frame/menu-registry.svelte";
10
+ import { anchoredMenu } from "@tinytars/frame/anchored-menu.svelte";
11
+
12
+ let {
13
+ email, emailConfirmed = true, providerAccess = false, onAccount, onAccess, onBackToRoster,
14
+ onResendVerification, onSignOut, onTranslate, translating = false, translateTitle, onDiagnostics, onVisibility, onDag,
15
+ onExport, onAbout,
16
+ providerBadgeLabel = "Provider access", subjectFallback = "record", viewingSubjectLabel = "Viewing",
17
+ backToRosterLabel = "← Back", accessLabel = "Who can access this",
18
+ translateLabel = "↻ Translate", translateBusyLabel = "Generating…",
19
+ translateBusyTitle = "This is still running — this takes a few minutes.",
20
+ }: {
21
+ email: string | null;
22
+ emailConfirmed?: boolean;
23
+ providerAccess?: boolean;
24
+ onAccount?: () => void;
25
+ onAccess?: () => void; // omitted for providers — they don't own a record to share
26
+ onBackToRoster?: () => void;
27
+ onResendVerification?: () => void;
28
+ onSignOut: () => void;
29
+ onTranslate?: () => void;
30
+ translating?: boolean;
31
+ translateTitle?: string;
32
+ onDiagnostics?: () => void;
33
+ onVisibility?: () => void;
34
+ onDag?: () => void;
35
+ // Export moved here from the sidebar's Profile row; omitted when no client is selected
36
+ // (provider roster / support console).
37
+ onExport?: () => void;
38
+ // About is app-level, not provider/patient-specific, so it's offered in both modes.
39
+ onAbout?: () => void;
40
+ // Copy, all domain-flavorable — defaults are generic, callers override with their own vocabulary
41
+ // (e.g. LexiTar's "patient"/"roster"/"Translation") rather than this package assuming one.
42
+ providerBadgeLabel?: string;
43
+ subjectFallback?: string;
44
+ viewingSubjectLabel?: string;
45
+ backToRosterLabel?: string;
46
+ accessLabel?: string;
47
+ translateLabel?: string;
48
+ translateBusyLabel?: string;
49
+ translateBusyTitle?: string;
50
+ } = $props();
51
+
52
+ // menuId identifies this instance in the shared menuRegistry so opening any other
53
+ // popover (a leaf row's LeafActionMenu) closes this one.
54
+ const menuId = $props.id();
55
+ let open = $derived(menuRegistry.isOpen(menuId));
56
+ let root: HTMLDivElement;
57
+ // Panel is portaled to <body> by the anchoredMenu action; outside-click has to
58
+ // check both `root` (the trigger) and `panelEl` (the portaled panel), mirroring LeafActionMenu.
59
+ let triggerEl: HTMLButtonElement;
60
+ let panelEl: HTMLDivElement | undefined;
61
+
62
+ const initials = $derived((email ?? "?").trim().slice(0, 1).toUpperCase());
63
+
64
+ function toggle() { if (open) menuRegistry.close(menuId); else menuRegistry.open(menuId); }
65
+ function close() { menuRegistry.close(menuId); }
66
+ function run(fn: () => void) { close(); fn(); }
67
+
68
+ function onWindowClick(e: MouseEvent) {
69
+ const target = e.target as Node;
70
+ if (!open) return;
71
+ if (root?.contains(target)) return;
72
+ if (panelEl?.contains(target)) return;
73
+ close();
74
+ }
75
+ function onKeydown(e: KeyboardEvent) {
76
+ if (e.key === "Escape") close();
77
+ }
78
+ </script>
79
+
80
+ <svelte:window onclick={onWindowClick} onkeydown={onKeydown} />
81
+
82
+ <div class="account-menu" bind:this={root}>
83
+ <button
84
+ bind:this={triggerEl}
85
+ class="account-trigger"
86
+ class:unverified={!emailConfirmed && !providerAccess}
87
+ class:provider={providerAccess}
88
+ aria-haspopup="menu"
89
+ aria-expanded={open}
90
+ aria-controls={`${menuId}-panel`}
91
+ title={providerAccess ? `${providerBadgeLabel} — viewing ${email ?? subjectFallback}` : (email ?? "Account")}
92
+ onclick={toggle}
93
+ >
94
+ <span class="avatar" aria-hidden="true">{initials}</span>
95
+ {#if providerAccess}<span class="badge">{providerBadgeLabel}</span>{/if}
96
+ <span class="chevron" aria-hidden="true">▾</span>
97
+ </button>
98
+
99
+ {#if open}
100
+ <div
101
+ id={`${menuId}-panel`}
102
+ class="menu"
103
+ role="menu"
104
+ bind:this={panelEl}
105
+ use:anchoredMenu={{ anchor: triggerEl, open }}
106
+ >
107
+ {#if email}
108
+ <div class="menu-head">
109
+ {#if providerAccess}<span class="menu-label">{viewingSubjectLabel}</span>{/if}
110
+ <span class="menu-email" title={email}>{email}</span>
111
+ {#if !emailConfirmed && !providerAccess}
112
+ <span class="menu-badge">Email not verified</span>
113
+ {/if}
114
+ </div>
115
+ {/if}
116
+ {#if providerAccess}
117
+ {#if onTranslate}
118
+ <button role="menuitem" class="menu-item" disabled={translating} title={translating ? translateBusyTitle : translateTitle} onclick={() => run(onTranslate!)}>{translating ? translateBusyLabel : translateLabel}</button>
119
+ {/if}
120
+ {#if onDiagnostics}
121
+ <button role="menuitem" class="menu-item" onclick={() => run(onDiagnostics!)}>Diagnostics</button>
122
+ {/if}
123
+ {#if onVisibility}
124
+ <button role="menuitem" class="menu-item" onclick={() => run(onVisibility!)}>Visibility</button>
125
+ {/if}
126
+ {#if onDag}
127
+ <button role="menuitem" class="menu-item" onclick={() => run(onDag!)}>DAG</button>
128
+ {/if}
129
+ {#if onBackToRoster}
130
+ <button role="menuitem" class="menu-item" onclick={() => run(onBackToRoster!)}>{backToRosterLabel}</button>
131
+ {/if}
132
+ {#if onAbout}
133
+ <button role="menuitem" class="menu-item" onclick={() => run(onAbout!)}>About</button>
134
+ {/if}
135
+ {#if onExport}
136
+ <button role="menuitem" class="menu-item" onclick={() => run(onExport!)}>Export</button>
137
+ {/if}
138
+ <div class="menu-sep"></div>
139
+ <button role="menuitem" class="menu-item danger" onclick={() => run(onSignOut)}>Sign out (provider)</button>
140
+ {:else}
141
+ {#if !emailConfirmed && onResendVerification}
142
+ <button role="menuitem" class="menu-item" onclick={() => run(onResendVerification!)}>Resend verification email</button>
143
+ {/if}
144
+ {#if onAccount}
145
+ <button role="menuitem" class="menu-item" onclick={() => run(onAccount!)}>Account settings</button>
146
+ {/if}
147
+ {#if onAccess}
148
+ <button role="menuitem" class="menu-item" onclick={() => run(onAccess!)}>{accessLabel}</button>
149
+ {/if}
150
+ {#if onAbout}
151
+ <button role="menuitem" class="menu-item" onclick={() => run(onAbout!)}>About</button>
152
+ {/if}
153
+ {#if onExport}
154
+ <button role="menuitem" class="menu-item" onclick={() => run(onExport!)}>Export</button>
155
+ {/if}
156
+ <div class="menu-sep"></div>
157
+ <button role="menuitem" class="menu-item danger" onclick={() => run(onSignOut)}>Sign out</button>
158
+ {/if}
159
+ </div>
160
+ {/if}
161
+ </div>
162
+
163
+ <style>
164
+ .account-menu { position: relative; display: block; width: 100%; }
165
+ .account-trigger {
166
+ display: flex; align-items: center; gap: 0.35rem; width: 100%; box-sizing: border-box;
167
+ padding: 0.25rem 0.5rem 0.25rem 0.3rem; border: 1px solid var(--border); background: white;
168
+ border-radius: 8px; font: inherit; cursor: pointer; min-height: 36px;
169
+ }
170
+ .account-trigger:hover { border-color: var(--accent); }
171
+ .avatar {
172
+ display: inline-flex; align-items: center; justify-content: center;
173
+ width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: white;
174
+ font-size: 0.8rem; font-weight: 600;
175
+ }
176
+ .account-trigger.unverified .avatar { background: var(--warn); }
177
+ .account-trigger.provider { border-color: var(--accent); }
178
+ .account-trigger.provider .avatar { background: var(--accent); }
179
+ .badge {
180
+ font-size: 0.65rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase;
181
+ color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent);
182
+ border-radius: 999px; padding: 0.1rem 0.5rem;
183
+ }
184
+ .menu-label { font-size: 0.65rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; color: var(--muted); }
185
+ .chevron { color: var(--muted); font-size: 0.7rem; margin-left: auto; }
186
+
187
+ /* top/left/max-height are set inline by the anchoredMenu action (portaled to
188
+ <body>, position: fixed, flip/clamp math in anchored-menu.svelte.ts) — no more hard-coded
189
+ `bottom: 100%`; this only sets the panel's own look, not its placement. */
190
+ .menu {
191
+ z-index: 90;
192
+ min-width: 240px; padding: 0.35rem; background: white;
193
+ border: 1px solid var(--border); border-radius: 10px;
194
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
195
+ display: flex; flex-direction: column; gap: 0.1rem;
196
+ }
197
+ .menu-head { padding: 0.4rem 0.6rem 0.5rem; display: flex; flex-direction: column; gap: 0.25rem; }
198
+ .menu-email { font-size: 0.85rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
199
+ .menu-badge {
200
+ align-self: flex-start; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase;
201
+ color: var(--warn); background: var(--warn-band); border: 1px solid var(--warn); border-radius: 999px; padding: 0.1rem 0.45rem;
202
+ }
203
+ .menu-item {
204
+ text-align: left; padding: 0.5rem 0.6rem; border: none; background: none; border-radius: 6px;
205
+ font: inherit; font-size: 0.9rem; color: var(--fg); cursor: pointer;
206
+ }
207
+ .menu-item:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
208
+ .menu-item.danger { color: var(--alert); }
209
+ .menu-sep { height: 1px; background: var(--border); margin: 0.25rem 0.3rem; }
210
+ </style>
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { registerAttachPicker, DEFAULT_ATTACH_ACCEPT, type AttachMode } from "./attach-controller";
4
+
5
+ // Mounted once (by App), owns the single hidden file input every leaf's
6
+ // "Attach" action shares via attach-controller.ts. One input, not three: the same element's
7
+ // `accept`/`capture` attributes are set right before each programmatic `.click()`, so "Take
8
+ // photo" / "Photo library" / "Choose file" don't need three separate elements per leaf row.
9
+ let inputEl: HTMLInputElement;
10
+ let onFilesCb: ((files: File[]) => void) | null = null;
11
+
12
+ function open(mode: AttachMode, onFiles: (files: File[]) => void, accept = DEFAULT_ATTACH_ACCEPT): void {
13
+ onFilesCb = onFiles;
14
+ if (mode === "camera") {
15
+ inputEl.accept = "image/*";
16
+ inputEl.setAttribute("capture", "environment");
17
+ } else {
18
+ inputEl.accept = accept;
19
+ inputEl.removeAttribute("capture");
20
+ }
21
+ inputEl.click();
22
+ }
23
+
24
+ onMount(() => registerAttachPicker(open));
25
+
26
+ function onChange(e: Event): void {
27
+ const input = e.currentTarget as HTMLInputElement;
28
+ const files = Array.from(input.files ?? []);
29
+ input.value = "";
30
+ const cb = onFilesCb;
31
+ onFilesCb = null;
32
+ if (files.length > 0) cb?.(files);
33
+ }
34
+ </script>
35
+
36
+ <input bind:this={inputEl} type="file" multiple style="display:none" onchange={onChange} />
@@ -0,0 +1,188 @@
1
+ <script module lang="ts">
2
+ // Domain-neutral shape for one operational-audit row (mirrors the consuming app's own log-entry
3
+ // type, kept structurally compatible so App can pass its own log fetcher straight through as
4
+ // fetchLog without either side importing the other's type).
5
+ export interface DiagnosticsLogEntry {
6
+ at: string;
7
+ event: string;
8
+ status: number;
9
+ attempt?: number;
10
+ chars?: number;
11
+ usage?: { input: number; output: number };
12
+ errorCode?: string;
13
+ reasonCategory?: string;
14
+ latencyMs?: number;
15
+ requestId?: string;
16
+ }
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ // Provider-only refresh diagnostics. Reads the PHI-free R2 audit trail via GET
21
+ // /api/logs and renders it as a table: outcome, attempt, token cost, latency — newest first. This is
22
+ // where a provider confirms "it tried 3 times, hit a truncation, and stopped" instead of guessing
23
+ // from a vanishing counter.
24
+ import { onMount } from "svelte";
25
+
26
+ interface Props {
27
+ token: string;
28
+ fetchLog: (token: string) => Promise<DiagnosticsLogEntry[]>;
29
+ noteText?: string;
30
+ emptyText?: string;
31
+ }
32
+ let {
33
+ token,
34
+ fetchLog,
35
+ noteText = "Operational trail of recent runs — outcome, attempts, and cost. Newest first (times UTC).",
36
+ emptyText = "No events recorded yet.",
37
+ }: Props = $props();
38
+
39
+ let entries = $state<DiagnosticsLogEntry[] | null>(null);
40
+ let error = $state<string | null>(null);
41
+ let loading = $state(true);
42
+
43
+ async function load() {
44
+ loading = true;
45
+ error = null;
46
+ try {
47
+ entries = await fetchLog(token);
48
+ } catch (e) {
49
+ error = (e as Error).message;
50
+ } finally {
51
+ loading = false;
52
+ }
53
+ }
54
+
55
+ onMount(load);
56
+
57
+ const time = (iso: string) => (iso.length >= 19 ? iso.slice(11, 19) : iso); // HH:MM:SS (UTC)
58
+ const day = (iso: string) => (iso.length >= 10 ? iso.slice(0, 10) : "");
59
+ const tok = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
60
+ const secs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
61
+ </script>
62
+
63
+ <div class="diag">
64
+ <div class="diag-head">
65
+ <p class="diag-note">{noteText}</p>
66
+ <button class="diag-reload" onclick={load} disabled={loading}>{loading ? "Loading…" : "↻ Reload"}</button>
67
+ </div>
68
+
69
+ {#if error}
70
+ <p class="diag-msg diag-error">Couldn’t load the log: {error}</p>
71
+ {:else if loading && !entries}
72
+ <p class="diag-msg">Loading…</p>
73
+ {:else if entries && entries.length === 0}
74
+ <p class="diag-msg">{emptyText}</p>
75
+ {:else if entries}
76
+ <table class="diag-table">
77
+ <thead>
78
+ <tr>
79
+ <th>Time</th>
80
+ <th>Event</th>
81
+ <th>Try</th>
82
+ <th>Tokens in/out</th>
83
+ <th>Chars</th>
84
+ <th>Reason / code</th>
85
+ <th>Latency</th>
86
+ </tr>
87
+ </thead>
88
+ <tbody>
89
+ {#each entries as e, i (e.at + e.event + (e.attempt ?? "") + (e.requestId ?? "") + i)}
90
+ <tr>
91
+ <td title={day(e.at)}>{time(e.at)}</td>
92
+ <td><span class="ev ev-{e.event}">{e.event}</span></td>
93
+ <td>{e.attempt ?? "—"}</td>
94
+ <td>{e.usage ? `${tok(e.usage.input)} / ${tok(e.usage.output)}` : "—"}</td>
95
+ <td>{e.chars ?? "—"}</td>
96
+ <td>{e.reasonCategory ?? e.errorCode ?? "—"}</td>
97
+ <td>{typeof e.latencyMs === "number" ? secs(e.latencyMs) : "—"}</td>
98
+ </tr>
99
+ {/each}
100
+ </tbody>
101
+ </table>
102
+ <p class="diag-scroll-hint">Scroll sideways for more columns →</p>
103
+ {/if}
104
+ </div>
105
+
106
+ <style>
107
+ .diag {
108
+ min-width: min(46rem, 80vw);
109
+ overflow-x: auto;
110
+ }
111
+ .diag-head {
112
+ display: flex;
113
+ align-items: flex-start;
114
+ justify-content: space-between;
115
+ gap: 1rem;
116
+ margin-bottom: 0.75rem;
117
+ }
118
+ .diag-note {
119
+ margin: 0;
120
+ font-size: 0.85rem;
121
+ color: var(--muted, #667);
122
+ max-width: 40rem;
123
+ }
124
+ .diag-reload {
125
+ flex: none;
126
+ font-size: 0.8rem;
127
+ padding: 0.25rem 0.6rem;
128
+ cursor: pointer;
129
+ }
130
+ .diag-msg {
131
+ font-size: 0.9rem;
132
+ color: var(--muted, #667);
133
+ }
134
+ .diag-error {
135
+ color: var(--alert);
136
+ }
137
+ .diag-table {
138
+ width: 100%;
139
+ border-collapse: collapse;
140
+ font-size: 0.82rem;
141
+ }
142
+ .diag-table th,
143
+ .diag-table td {
144
+ text-align: left;
145
+ padding: 0.3rem 0.5rem;
146
+ border-bottom: 1px solid var(--border, #e3e3ea);
147
+ white-space: nowrap;
148
+ }
149
+ .diag-table th {
150
+ font-weight: 600;
151
+ color: var(--muted, #667);
152
+ }
153
+ /* The table is horizontal-scroll-only (no visible scrollbar affordance on touch
154
+ browsers); a text hint makes that discoverable on phone widths. */
155
+ .diag-scroll-hint {
156
+ display: none;
157
+ margin: 0.4rem 0 0;
158
+ font-size: 0.78rem;
159
+ color: var(--muted, #667);
160
+ }
161
+ @media (max-width: 640px) {
162
+ .diag-scroll-hint { display: block; }
163
+ }
164
+ .ev {
165
+ font-family: ui-monospace, monospace;
166
+ font-size: 0.78rem;
167
+ padding: 0.05rem 0.35rem;
168
+ border-radius: 0.25rem;
169
+ background: var(--band);
170
+ color: var(--accent);
171
+ }
172
+ .ev-success {
173
+ background: var(--safe-band);
174
+ color: var(--safe);
175
+ }
176
+ .ev-error,
177
+ .ev-gave-up,
178
+ .ev-truncated {
179
+ background: var(--alert-band);
180
+ color: var(--alert);
181
+ }
182
+ .ev-validation-fail,
183
+ .ev-aborted,
184
+ .ev-cancelled {
185
+ background: var(--warn-band);
186
+ color: var(--warn);
187
+ }
188
+ </style>
@@ -0,0 +1,86 @@
1
+ <script module lang="ts">
2
+ export interface ExportOption {
3
+ key: string;
4
+ title: string;
5
+ description: string;
6
+ buttonLabel: string;
7
+ onClick?: () => void; // omitted (with disabled) for a not-yet-available option
8
+ disabled?: boolean;
9
+ badge?: string;
10
+ }
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ // A generic "take this record out of the app" panel. The list of destinations (what they
15
+ // export, in what format, whether they're live yet) is entirely caller-supplied, so this
16
+ // component carries no knowledge of vaults, markers, or any other domain data shape.
17
+ let {
18
+ title = "Export",
19
+ lead = "Take this record out of the app. Everything here is generated on your device — nothing is sent anywhere.",
20
+ options,
21
+ }: {
22
+ title?: string;
23
+ lead?: string;
24
+ options: ExportOption[];
25
+ } = $props();
26
+ </script>
27
+
28
+ <div class="export-tab leaf-section">
29
+ <h2>{title}</h2>
30
+ <p class="lead">{lead}</p>
31
+
32
+ <ul class="export-options">
33
+ {#each options as opt (opt.key)}
34
+ <li class:soon={opt.disabled}>
35
+ <div class="opt-text">
36
+ <h3>{opt.title}{#if opt.badge}<span class="soon-tag">{opt.badge}</span>{/if}</h3>
37
+ <p>{opt.description}</p>
38
+ </div>
39
+ <button class="primary" disabled={opt.disabled} onclick={opt.onClick}>{opt.buttonLabel}</button>
40
+ </li>
41
+ {/each}
42
+ </ul>
43
+ </div>
44
+
45
+ <style>
46
+ h2 { margin: 0 0 0.4rem; font-size: 1.25rem; }
47
+ .lead { color: var(--muted); margin: 0 0 1.5rem; line-height: 1.5; }
48
+ .export-options { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.75rem; }
49
+ .export-options li {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 1rem;
53
+ padding: 1rem 1.1rem;
54
+ border: 1px solid var(--border);
55
+ border-radius: 10px;
56
+ background: white;
57
+ }
58
+ .opt-text { flex: 1; min-width: 0; }
59
+ .opt-text h3 { margin: 0 0 0.25rem; font-size: 1rem; }
60
+ .opt-text p { margin: 0; color: var(--muted); font-size: 0.88rem; line-height: 1.45; }
61
+ .primary {
62
+ flex: 0 0 auto;
63
+ padding: 0.6rem 1rem;
64
+ border: 1px solid var(--accent);
65
+ border-radius: 8px;
66
+ background: var(--accent);
67
+ color: white;
68
+ font: inherit;
69
+ font-weight: 600;
70
+ cursor: pointer;
71
+ min-height: 44px;
72
+ white-space: nowrap;
73
+ }
74
+ .primary:hover { filter: brightness(1.05); }
75
+ .primary:disabled { background: var(--border); border-color: var(--border); color: var(--muted); cursor: default; }
76
+ .soon { opacity: 0.8; }
77
+ .soon-tag {
78
+ font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
79
+ color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.4rem; margin-left: 0.4rem; vertical-align: middle;
80
+ }
81
+ @media (max-width: 560px) {
82
+ .export-options li { flex-direction: column; align-items: stretch; }
83
+ .primary { width: 100%; }
84
+ }
85
+ @media print { .export-tab { display: none !important; } }
86
+ </style>
@@ -0,0 +1,168 @@
1
+ <script module lang="ts">
2
+ import type { LeafMenuItem } from "./menu-items";
3
+ export function openOnly(onOpen?: () => void): LeafMenuItem[] {
4
+ return onOpen ? [{ label: "Open", onClick: onOpen }] : [];
5
+ }
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ import { menuRegistry } from "./menu-registry.svelte";
10
+ import { anchoredMenu } from "./anchored-menu.svelte";
11
+
12
+ // The one shared triple-dot action menu every leaf row uses. Generalizes
13
+ // AccountMenu.svelte's dropdown (trigger + outside-click/Escape-closing role="menu" panel) with a
14
+ // generic items[] prop instead of named callback props, and a ⋮ trigger instead of an avatar.
15
+ // Boolean favorite/pin toggles are NOT items here — they stay their own standalone control next
16
+ // to (not inside) this menu, everywhere a leaf has one.
17
+ interface Props {
18
+ items: LeafMenuItem[];
19
+ label?: string;
20
+ icon?: string;
21
+ pinned?: boolean;
22
+ onTogglePin?: () => void;
23
+ pinDisplay?: "auto" | "hidden";
24
+ }
25
+ let { items, label = "Actions", icon = "⋮", pinned = false, onTogglePin, pinDisplay = "auto" }: Props = $props();
26
+
27
+ // menuId identifies this instance in the shared menuRegistry so opening any other
28
+ // popover (another leaf row, or AccountMenu) closes this one.
29
+ const menuId = $props.id();
30
+ let open = $derived(menuRegistry.isOpen(menuId));
31
+ let root: HTMLDivElement;
32
+ // The panel itself is portaled to <body> by the anchoredMenu action (so no
33
+ // ancestor's overflow/sticky/transform can clip it), so it's no longer a descendant of `root` —
34
+ // outside-click has to check both. `triggerEl` binds whichever of the two mutually-exclusive
35
+ // trigger buttons below is actually rendered.
36
+ let triggerEl: HTMLButtonElement;
37
+ let panelEl: HTMLDivElement | undefined;
38
+ let menuItems = $derived(
39
+ onTogglePin ? [{ label: pinned ? "Unpin" : "Pin", onClick: onTogglePin }, ...items] : items
40
+ );
41
+
42
+ function toggle() { if (open) menuRegistry.close(menuId); else menuRegistry.open(menuId); }
43
+ function close() { menuRegistry.close(menuId); }
44
+ function run(item: LeafMenuItem) {
45
+ if (item.disabled) return;
46
+ close();
47
+ item.onClick();
48
+ }
49
+
50
+ function onWindowClick(e: MouseEvent) {
51
+ const target = e.target as Node;
52
+ if (!open) return;
53
+ if (root?.contains(target)) return;
54
+ if (panelEl?.contains(target)) return;
55
+ close();
56
+ }
57
+ function onKeydown(e: KeyboardEvent) {
58
+ if (e.key === "Escape") close();
59
+ }
60
+ </script>
61
+
62
+ <svelte:window onclick={onWindowClick} onkeydown={onKeydown} />
63
+
64
+ <div class="leaf-menu" bind:this={root}>
65
+ {#if onTogglePin && pinDisplay === "auto"}
66
+ <div class="pin-slot">
67
+ <span class="pin-star" class:visible={pinned} aria-hidden="true">★</span>
68
+ <button
69
+ bind:this={triggerEl}
70
+ class="leaf-menu-trigger pin-trigger"
71
+ aria-haspopup="menu"
72
+ aria-expanded={open}
73
+ aria-controls={`${menuId}-panel`}
74
+ title={label}
75
+ onclick={toggle}
76
+ >{icon}</button>
77
+ </div>
78
+ {:else}
79
+ <button
80
+ bind:this={triggerEl}
81
+ class="leaf-menu-trigger"
82
+ aria-haspopup="menu"
83
+ aria-expanded={open}
84
+ aria-controls={`${menuId}-panel`}
85
+ title={label}
86
+ onclick={toggle}
87
+ >{icon}</button>
88
+ {/if}
89
+
90
+ {#if open}
91
+ <div
92
+ id={`${menuId}-panel`}
93
+ class="menu"
94
+ role="menu"
95
+ bind:this={panelEl}
96
+ use:anchoredMenu={{ anchor: triggerEl, open }}
97
+ >
98
+ {#each menuItems as item, i (item.key ?? item.title ?? item.label)}
99
+ {#if item.danger && !menuItems[i - 1]?.danger}<div class="menu-sep"></div>{/if}
100
+ <button
101
+ role="menuitem"
102
+ class="menu-item"
103
+ class:danger={item.danger}
104
+ disabled={item.disabled}
105
+ title={item.title ?? item.label}
106
+ onclick={() => run(item)}
107
+ >{item.icon ? `${item.icon} ` : ""}{item.label}</button>
108
+ {/each}
109
+ </div>
110
+ {/if}
111
+ </div>
112
+
113
+ <style>
114
+ .leaf-menu { position: relative; display: inline-block; }
115
+ .leaf-menu-trigger {
116
+ display: inline-flex; align-items: center; justify-content: center;
117
+ width: 28px; height: 28px; padding: 0; border: none; background: none;
118
+ border-radius: 6px; font: inherit; font-size: 1.1rem; line-height: 1; color: var(--muted);
119
+ cursor: pointer;
120
+ }
121
+ .leaf-menu-trigger:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
122
+
123
+ .pin-slot { display: inline-flex; align-items: center; gap: 0.15rem; }
124
+ .pin-slot .pin-star { display: none; font-size: 0.75rem; line-height: 1; color: var(--accent); }
125
+ .pin-slot .pin-star.visible { display: inline-block; }
126
+
127
+ @media (hover: hover) and (pointer: fine) {
128
+ .pin-slot { position: relative; width: 28px; height: 28px; gap: 0; }
129
+ .pin-slot .pin-star {
130
+ position: absolute; inset: 0; display: none;
131
+ align-items: center; justify-content: center; font-size: 0.95rem;
132
+ }
133
+ .pin-slot .pin-star.visible { display: flex; }
134
+ .pin-slot:hover .pin-star { display: none; }
135
+ .pin-slot .leaf-menu-trigger.pin-trigger { position: absolute; inset: 0; display: none; }
136
+ .pin-slot:hover .leaf-menu-trigger.pin-trigger { display: inline-flex; }
137
+ }
138
+
139
+ /* Bring the trigger up to the app's 44px touch-target convention on phone widths.
140
+ Only .leaf-menu-trigger itself gets a fixed box; .pin-slot stays auto-width (it's a flex row
141
+ that, on touch, can hold the trigger AND a visible ★ status badge side by side — forcing it
142
+ square would clip that). The (hover: hover) and (pointer: fine) block above is unaffected
143
+ since a touch viewport has no fine hover pointer, so the two rules never fight the same box. */
144
+ @media (max-width: 640px) {
145
+ .leaf-menu-trigger { width: 44px; height: 44px; }
146
+ .pin-slot { min-height: 44px; }
147
+ }
148
+
149
+ /* Top/left/max-height are set inline by the anchoredMenu action (portaled to
150
+ <body>, position: fixed, flip/clamp math in anchored-menu.svelte.ts); this only sets the
151
+ panel's own look, not its placement. */
152
+ .menu {
153
+ z-index: 90;
154
+ min-width: 160px; padding: 0.3rem; background: white;
155
+ border: 1px solid var(--border); border-radius: 10px;
156
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
157
+ display: flex; flex-direction: column; gap: 0.1rem;
158
+ }
159
+ .menu-item {
160
+ text-align: left; padding: 0.4rem 0.55rem; border: none; background: none; border-radius: 6px;
161
+ font: inherit; font-size: 0.85rem; color: var(--fg); cursor: pointer; white-space: nowrap;
162
+ }
163
+ .menu-item:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
164
+ .menu-item:disabled { color: var(--muted); cursor: not-allowed; }
165
+ .menu-item:disabled:hover { background: none; }
166
+ .menu-item.danger { color: var(--alert); }
167
+ .menu-sep { height: 1px; background: var(--border); margin: 0.25rem 0.3rem; }
168
+ </style>