@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.
- package/AccountMenu.svelte +210 -0
- package/AttachPicker.svelte +36 -0
- package/Diagnostics.svelte +188 -0
- package/ExportTab.svelte +86 -0
- package/LeafActionMenu.svelte +168 -0
- package/LeafCard.svelte +72 -0
- package/LoginScreen.svelte +124 -0
- package/Onboarding.svelte +122 -0
- package/VisibilitySettings.svelte +62 -0
- package/account-methods.svelte.ts +321 -0
- package/anchored-menu.svelte.ts +208 -0
- package/attach-controller.ts +27 -0
- package/brand.ts +8 -0
- package/menu-items.ts +17 -0
- package/menu-registry.svelte.ts +18 -0
- package/package.json +65 -0
- package/recovery-controller.svelte.ts +301 -0
- package/roster-session.svelte.ts +263 -0
- package/support-access.svelte.ts +229 -0
- package/vault-principals.svelte.ts +245 -0
- package/vault-session.svelte.ts +77 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// The Account panel: who this account is, and every way there is into it.
|
|
2
|
+
//
|
|
3
|
+
// App's fourth controller extraction. Adding and removing sign-in methods is the only
|
|
4
|
+
// cluster in the component that can lock a person out of their own health record, and the rule that
|
|
5
|
+
// stops it — never remove the last way in — lived in a `disabled` attribute on a button. The server
|
|
6
|
+
// enforces it too (functions/api/account/methods.ts:126), so this was never a hole; it was an
|
|
7
|
+
// invariant with no name and no test, expressed as a UI affordance. It has one of each now.
|
|
8
|
+
//
|
|
9
|
+
// Same getter-parameterized factory shape as vault-principals.svelte.ts, recovery-controller.svelte.ts
|
|
10
|
+
// and support-access.svelte.ts. What stays with App is what App owns: the vault principals and access
|
|
11
|
+
// events the owner-only blocks render, and the unit system every tab reads.
|
|
12
|
+
|
|
13
|
+
import { getMyAccount, addPasskeyMethod, addGoogleMethod } from "@tinytars/vault/auth-client";
|
|
14
|
+
import { listMethods, updateProfile, addPasswordMethod, removeMethod, type LoginMethod } from "@tinytars/vault/auth-recovery";
|
|
15
|
+
|
|
16
|
+
export type RemovableMethod = "password" | "passkey" | "google";
|
|
17
|
+
|
|
18
|
+
export interface AccountInfo {
|
|
19
|
+
email: string | null;
|
|
20
|
+
displayName: string;
|
|
21
|
+
emailConfirmed: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AccountMethodsDeps {
|
|
25
|
+
/**
|
|
26
|
+
* The in-memory account private key — `session.ownerKey ?? session.providerKey`, read fresh because
|
|
27
|
+
* a resume replaces it. Every add re-wraps it, which is why extractability decides the whole panel.
|
|
28
|
+
*/
|
|
29
|
+
getPrivateKey: () => CryptoKey | null;
|
|
30
|
+
/** A provider session has no vault of its own, so it gets no recovery or access-log blocks. */
|
|
31
|
+
isProviderSession: () => boolean;
|
|
32
|
+
/** The unit system rides on the account payload but belongs to the shell — every tab reads it. */
|
|
33
|
+
applyUnitSystem: (u: "metric" | "imperial") => void;
|
|
34
|
+
/** Vault principals + access events. App's, because App owns the recovery controller they feed. */
|
|
35
|
+
loadOwnerBlocks: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AccountMethods {
|
|
39
|
+
readonly open: boolean;
|
|
40
|
+
readonly busy: boolean;
|
|
41
|
+
readonly error: string | null;
|
|
42
|
+
readonly info: AccountInfo | null;
|
|
43
|
+
readonly methods: LoginMethod[];
|
|
44
|
+
/** The verify-email banner's transient note. The persistent state is `info.emailConfirmed`. */
|
|
45
|
+
emailVerifyNote: "ok" | "invalid" | "sent" | null;
|
|
46
|
+
/** Bound to the profile form. */
|
|
47
|
+
editEmail: string;
|
|
48
|
+
editDisplayName: string;
|
|
49
|
+
/** Bound to the add-password field. */
|
|
50
|
+
newPassword: string;
|
|
51
|
+
|
|
52
|
+
/** Methods that can actually sign someone in — recovery is a way back, not a way in. */
|
|
53
|
+
readonly loginMethods: LoginMethod[];
|
|
54
|
+
readonly hasPassword: boolean;
|
|
55
|
+
readonly hasPasskey: boolean;
|
|
56
|
+
readonly hasGoogle: boolean;
|
|
57
|
+
readonly hasRecovery: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Why this method cannot be removed, or null when it can. A string rather than a boolean because
|
|
60
|
+
* the button needs the sentence for its tooltip, and one source beats a parallel condition.
|
|
61
|
+
*/
|
|
62
|
+
removeBlockedReason(method: RemovableMethod): string | null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* After a plain-refresh resume the account key is the non-extractable copy restored from
|
|
66
|
+
* IndexedDB, so it cannot be re-wrapped under a new KEK. Adding or changing a login method needs a
|
|
67
|
+
* fresh sign-in; everything else works unchanged. Public because the recovery ladder re-wraps too.
|
|
68
|
+
*/
|
|
69
|
+
ensureExtractableKey(): CryptoKey | null;
|
|
70
|
+
|
|
71
|
+
openPanel(): Promise<void>;
|
|
72
|
+
closePanel(): void;
|
|
73
|
+
/** Re-reads account and methods from the server. Every mutation below ends here. */
|
|
74
|
+
refresh(): Promise<void>;
|
|
75
|
+
resendVerification(): Promise<void>;
|
|
76
|
+
saveProfile(): Promise<void>;
|
|
77
|
+
addPassword(): Promise<void>;
|
|
78
|
+
addPasskey(): Promise<void>;
|
|
79
|
+
remove(method: RemovableMethod): Promise<void>;
|
|
80
|
+
/** Runs the Google link popup and finishes the link when it reports back. */
|
|
81
|
+
connectGoogle(): void;
|
|
82
|
+
/** Reports through this panel's error line — the popup's failures are the panel's. */
|
|
83
|
+
setError(message: string | null): void;
|
|
84
|
+
setBusy(busy: boolean): void;
|
|
85
|
+
reset(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
|
|
89
|
+
let open = $state(false);
|
|
90
|
+
let busy = $state(false);
|
|
91
|
+
let error = $state<string | null>(null);
|
|
92
|
+
let info = $state<AccountInfo | null>(null);
|
|
93
|
+
let methods = $state<LoginMethod[]>([]);
|
|
94
|
+
let editEmail = $state("");
|
|
95
|
+
let editDisplayName = $state("");
|
|
96
|
+
let newPassword = $state("");
|
|
97
|
+
let emailVerifyNote = $state<"ok" | "invalid" | "sent" | null>(null);
|
|
98
|
+
|
|
99
|
+
const loginMethods = $derived(methods.filter((m) => !m.isRecovery && m.method !== "recovery"));
|
|
100
|
+
const hasPassword = $derived(loginMethods.some((m) => m.method === "password"));
|
|
101
|
+
const hasPasskey = $derived(loginMethods.some((m) => m.method === "passkey"));
|
|
102
|
+
const hasGoogle = $derived(loginMethods.some((m) => m.method === "google"));
|
|
103
|
+
const hasRecovery = $derived(methods.some((m) => m.isRecovery || m.method === "recovery"));
|
|
104
|
+
|
|
105
|
+
function ensureExtractableKey(): CryptoKey | null {
|
|
106
|
+
const pk = deps.getPrivateKey();
|
|
107
|
+
if (!pk) return null;
|
|
108
|
+
if (!pk.extractable) {
|
|
109
|
+
error = "For your security, sign out and sign in again to add or change a login method.";
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return pk;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function refresh(): Promise<void> {
|
|
116
|
+
const [account, list] = await Promise.all([getMyAccount(), listMethods()]);
|
|
117
|
+
info = { email: account.email, displayName: account.displayName, emailConfirmed: account.emailConfirmed };
|
|
118
|
+
methods = list;
|
|
119
|
+
deps.applyUnitSystem(account.unitSystem ?? "imperial");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The busy/error envelope every mutation shares. Each one ends by re-reading the server. */
|
|
123
|
+
async function mutate(body: () => Promise<void>): Promise<void> {
|
|
124
|
+
busy = true;
|
|
125
|
+
try {
|
|
126
|
+
await body();
|
|
127
|
+
await refresh();
|
|
128
|
+
} catch (e) {
|
|
129
|
+
error = (e as Error).message;
|
|
130
|
+
} finally {
|
|
131
|
+
busy = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function removeBlockedReason(method: RemovableMethod): string | null {
|
|
136
|
+
if (!loginMethods.some((m) => m.method === method)) return null;
|
|
137
|
+
// The server refuses this too. Both are the invariant: the server so a crafted request cannot
|
|
138
|
+
// do it, and here so the person is told before they try rather than after.
|
|
139
|
+
return loginMethods.length <= 1 ? "You can't remove your only sign-in method" : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
get open() {
|
|
144
|
+
return open;
|
|
145
|
+
},
|
|
146
|
+
get busy() {
|
|
147
|
+
return busy;
|
|
148
|
+
},
|
|
149
|
+
get error() {
|
|
150
|
+
return error;
|
|
151
|
+
},
|
|
152
|
+
get info() {
|
|
153
|
+
return info;
|
|
154
|
+
},
|
|
155
|
+
get methods() {
|
|
156
|
+
return methods;
|
|
157
|
+
},
|
|
158
|
+
get emailVerifyNote() {
|
|
159
|
+
return emailVerifyNote;
|
|
160
|
+
},
|
|
161
|
+
set emailVerifyNote(v: "ok" | "invalid" | "sent" | null) {
|
|
162
|
+
emailVerifyNote = v;
|
|
163
|
+
},
|
|
164
|
+
get editEmail() {
|
|
165
|
+
return editEmail;
|
|
166
|
+
},
|
|
167
|
+
set editEmail(v: string) {
|
|
168
|
+
editEmail = v;
|
|
169
|
+
},
|
|
170
|
+
get editDisplayName() {
|
|
171
|
+
return editDisplayName;
|
|
172
|
+
},
|
|
173
|
+
set editDisplayName(v: string) {
|
|
174
|
+
editDisplayName = v;
|
|
175
|
+
},
|
|
176
|
+
get newPassword() {
|
|
177
|
+
return newPassword;
|
|
178
|
+
},
|
|
179
|
+
set newPassword(v: string) {
|
|
180
|
+
newPassword = v;
|
|
181
|
+
},
|
|
182
|
+
get loginMethods() {
|
|
183
|
+
return loginMethods;
|
|
184
|
+
},
|
|
185
|
+
get hasPassword() {
|
|
186
|
+
return hasPassword;
|
|
187
|
+
},
|
|
188
|
+
get hasPasskey() {
|
|
189
|
+
return hasPasskey;
|
|
190
|
+
},
|
|
191
|
+
get hasGoogle() {
|
|
192
|
+
return hasGoogle;
|
|
193
|
+
},
|
|
194
|
+
get hasRecovery() {
|
|
195
|
+
return hasRecovery;
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
removeBlockedReason,
|
|
199
|
+
ensureExtractableKey,
|
|
200
|
+
|
|
201
|
+
async openPanel() {
|
|
202
|
+
open = true;
|
|
203
|
+
error = null;
|
|
204
|
+
try {
|
|
205
|
+
const [account, list] = await Promise.all([getMyAccount(), listMethods()]);
|
|
206
|
+
info = { email: account.email, displayName: account.displayName, emailConfirmed: account.emailConfirmed };
|
|
207
|
+
methods = list;
|
|
208
|
+
editEmail = account.email ?? "";
|
|
209
|
+
editDisplayName = account.displayName;
|
|
210
|
+
// The recovery and access-log blocks only apply to an owned vault; a provider
|
|
211
|
+
// session has none, and asking for principals it does not have is a 403 on every open.
|
|
212
|
+
if (!deps.isProviderSession()) await deps.loadOwnerBlocks();
|
|
213
|
+
} catch (e) {
|
|
214
|
+
error = (e as Error).message;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
closePanel() {
|
|
219
|
+
open = false;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
refresh,
|
|
223
|
+
|
|
224
|
+
// Best-effort by design: verification never gates the app, so a failure to send is not
|
|
225
|
+
// worth a red line across a panel the person opened to do something else.
|
|
226
|
+
async resendVerification() {
|
|
227
|
+
try {
|
|
228
|
+
await fetch("/api/account/email/send-verification", { method: "POST" });
|
|
229
|
+
emailVerifyNote = "sent";
|
|
230
|
+
} catch {
|
|
231
|
+
/* best-effort */
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async saveProfile() {
|
|
236
|
+
error = null;
|
|
237
|
+
await mutate(() => updateProfile({ email: editEmail.trim() || undefined, displayName: editDisplayName.trim() || undefined }));
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async addPassword() {
|
|
241
|
+
if (!newPassword) return;
|
|
242
|
+
error = null;
|
|
243
|
+
const pk = ensureExtractableKey();
|
|
244
|
+
if (!pk) return;
|
|
245
|
+
const password = newPassword;
|
|
246
|
+
await mutate(async () => {
|
|
247
|
+
await addPasswordMethod(pk, password);
|
|
248
|
+
// Cleared inside the try: a rejected password (too short, step-up refused) must stay in the
|
|
249
|
+
// field, or the retry is a re-entry of something the person already typed once.
|
|
250
|
+
newPassword = "";
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async addPasskey() {
|
|
255
|
+
error = null;
|
|
256
|
+
const pk = ensureExtractableKey();
|
|
257
|
+
if (!pk) return;
|
|
258
|
+
await mutate(() => addPasskeyMethod(pk));
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async remove(method: RemovableMethod) {
|
|
262
|
+
error = null;
|
|
263
|
+
const blocked = removeBlockedReason(method);
|
|
264
|
+
if (blocked) {
|
|
265
|
+
error = blocked;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await mutate(() => removeMethod(method));
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
// OAuth runs in a POPUP so this SPA (and the in-memory key the server needs to wrap)
|
|
272
|
+
// survives; the popup postMessages back once the signed link cookie is set, and only then do we
|
|
273
|
+
// hand the server the private key to finish the link.
|
|
274
|
+
connectGoogle() {
|
|
275
|
+
error = null;
|
|
276
|
+
const pk = ensureExtractableKey();
|
|
277
|
+
if (!pk) return;
|
|
278
|
+
const popup = window.open("/api/auth/google/start?mode=link", "hd-google", "popup,width=520,height=640");
|
|
279
|
+
if (!popup) {
|
|
280
|
+
error = "Enable pop-ups for this site to connect Google.";
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const onMessage = async (ev: MessageEvent) => {
|
|
284
|
+
if (ev.origin !== window.location.origin) return;
|
|
285
|
+
const data = ev.data as { type?: string; error?: string };
|
|
286
|
+
if (data?.type !== "hd-google-linked" && data?.type !== "hd-google-error") return;
|
|
287
|
+
window.removeEventListener("message", onMessage);
|
|
288
|
+
if (data.type === "hd-google-error") {
|
|
289
|
+
error =
|
|
290
|
+
data.error === "linked_elsewhere"
|
|
291
|
+
? "That Google account is already linked to another account."
|
|
292
|
+
: data.error === "not_signed_in"
|
|
293
|
+
? "Your session expired — sign in again."
|
|
294
|
+
: "Google sign-in failed.";
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await mutate(() => addGoogleMethod(pk));
|
|
298
|
+
};
|
|
299
|
+
window.addEventListener("message", onMessage);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
setError(message: string | null) {
|
|
303
|
+
error = message;
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
setBusy(b: boolean) {
|
|
307
|
+
busy = b;
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
reset() {
|
|
311
|
+
open = false;
|
|
312
|
+
busy = false;
|
|
313
|
+
error = null;
|
|
314
|
+
info = null;
|
|
315
|
+
methods = [];
|
|
316
|
+
editEmail = "";
|
|
317
|
+
editDisplayName = "";
|
|
318
|
+
newPassword = "";
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Shared collision-aware positioning for popover panels (LeafActionMenu, AccountMenu).
|
|
2
|
+
// Both used to be `position: absolute` with a hard-coded direction (`top: 100%` / `bottom: 100%`),
|
|
3
|
+
// so a trigger inside a sticky-bottom container (the chat composer) opened its menu straight off the
|
|
4
|
+
// bottom of the viewport. This action portals the panel to <body> (so no ancestor's `overflow` or
|
|
5
|
+
// `sticky`/`transform` can clip it) and keeps it positioned in `position: fixed` space, flipping to
|
|
6
|
+
// whichever side has room and clamping into the viewport when neither side fully fits.
|
|
7
|
+
|
|
8
|
+
export interface Rect {
|
|
9
|
+
top: number;
|
|
10
|
+
left: number;
|
|
11
|
+
right: number;
|
|
12
|
+
bottom: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PanelSize {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
export interface Viewport {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
export interface MenuPlacement {
|
|
23
|
+
top: number;
|
|
24
|
+
left: number;
|
|
25
|
+
maxHeight?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GAP = 4;
|
|
29
|
+
const MARGIN = 8;
|
|
30
|
+
|
|
31
|
+
// Pure so it's unit-testable without a DOM: prefers below-and-right-aligned (the panel's original
|
|
32
|
+
// static behavior), flips above when below doesn't fit and above has more room, and as a last
|
|
33
|
+
// resort clamps to whichever side has more room with a max-height + scroll instead of ever
|
|
34
|
+
// letting either edge escape the viewport.
|
|
35
|
+
export function placeMenu(trigger: Rect, panel: PanelSize, viewport: Viewport): MenuPlacement {
|
|
36
|
+
const spaceBelow = viewport.height - trigger.bottom - GAP;
|
|
37
|
+
const spaceAbove = trigger.top - GAP;
|
|
38
|
+
const fitsBelow = panel.height <= spaceBelow;
|
|
39
|
+
const fitsAbove = panel.height <= spaceAbove;
|
|
40
|
+
|
|
41
|
+
let top: number;
|
|
42
|
+
let maxHeight: number | undefined;
|
|
43
|
+
|
|
44
|
+
if (fitsBelow || (!fitsAbove && spaceBelow >= spaceAbove)) {
|
|
45
|
+
top = trigger.bottom + GAP;
|
|
46
|
+
if (!fitsBelow) maxHeight = Math.max(spaceBelow - MARGIN, 80);
|
|
47
|
+
} else if (fitsAbove) {
|
|
48
|
+
top = trigger.top - GAP - panel.height;
|
|
49
|
+
} else {
|
|
50
|
+
top = MARGIN;
|
|
51
|
+
maxHeight = Math.max(spaceAbove - MARGIN, 80);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The 80px floors above are a readability minimum — a 20px-tall scrolling menu is useless — but
|
|
55
|
+
// they are applied to a max-height, and nothing was stopping `top + height` from landing past the
|
|
56
|
+
// viewport's bottom edge. On a short viewport with a tall menu (both `!fitsBelow` and `!fitsAbove`,
|
|
57
|
+
// with more room below) the floor won and the panel's last item sat off-screen: visible, enabled
|
|
58
|
+
// and stable to Playwright, and permanently unclickable — "element is outside of the viewport",
|
|
59
|
+
// retried until the test timed out. The header comment claimed this could not happen; now it holds.
|
|
60
|
+
const height = Math.min(panel.height, maxHeight ?? panel.height);
|
|
61
|
+
top = Math.min(top, viewport.height - MARGIN - height);
|
|
62
|
+
top = Math.max(top, MARGIN);
|
|
63
|
+
|
|
64
|
+
let left = trigger.right - panel.width;
|
|
65
|
+
left = Math.min(left, viewport.width - panel.width - MARGIN);
|
|
66
|
+
left = Math.max(left, MARGIN);
|
|
67
|
+
|
|
68
|
+
return { top, left, maxHeight };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AnchoredMenuParams {
|
|
72
|
+
anchor: HTMLElement;
|
|
73
|
+
open: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Svelte action for the panel element. `anchor` is the trigger button; `open` mirrors the caller's
|
|
77
|
+
// own open state (the action doesn't own visibility — the {#if} around the panel does — it only
|
|
78
|
+
// needs `open` to know when to (re)compute a placement).
|
|
79
|
+
export function anchoredMenu(node: HTMLElement, params: AnchoredMenuParams) {
|
|
80
|
+
let anchor = params.anchor;
|
|
81
|
+
let open = params.open;
|
|
82
|
+
let rafId: number | null = null;
|
|
83
|
+
|
|
84
|
+
node.style.position = "fixed";
|
|
85
|
+
node.style.margin = "0";
|
|
86
|
+
document.body.appendChild(node);
|
|
87
|
+
|
|
88
|
+
function reposition() {
|
|
89
|
+
if (!open) return;
|
|
90
|
+
const panelRect = node.getBoundingClientRect();
|
|
91
|
+
// `scrollHeight` is the panel's CONTENT height, so it ignores the max-height this function may
|
|
92
|
+
// have set on an earlier pass. Reading the box back instead would feed each placement its own
|
|
93
|
+
// clamped output and ratchet the panel smaller every time it re-ran — harmless while the only
|
|
94
|
+
// triggers were scroll and resize, but not once the observer below re-runs it on our own write.
|
|
95
|
+
const height = Math.max(node.scrollHeight, panelRect.height);
|
|
96
|
+
// A zero measurement is not a small panel, it is one that has not been laid out yet, and placing
|
|
97
|
+
// on it is worse than not placing at all: placeMenu clamps against the height it is GIVEN, so a
|
|
98
|
+
// height of 0 parks the panel flush against the bottom edge and the real content then renders
|
|
99
|
+
// past the fold. Wait for the observer instead.
|
|
100
|
+
if (height === 0 || panelRect.width === 0) return;
|
|
101
|
+
const placement = placeMenu(
|
|
102
|
+
anchor.getBoundingClientRect(),
|
|
103
|
+
{ width: panelRect.width, height },
|
|
104
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
105
|
+
);
|
|
106
|
+
node.style.top = `${placement.top}px`;
|
|
107
|
+
node.style.left = `${placement.left}px`;
|
|
108
|
+
node.style.maxHeight = placement.maxHeight != null ? `${placement.maxHeight}px` : "";
|
|
109
|
+
node.style.overflowY = placement.maxHeight != null ? "auto" : "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// rAF-throttled: scroll/resize can fire many times per frame, and the composer's sticky-bottom
|
|
113
|
+
// scroll is exactly the case this exists to keep smooth. Deliberately does NOT close the menu on
|
|
114
|
+
// scroll — the chat composer's page scrolls under an open menu routinely.
|
|
115
|
+
function scheduleReposition() {
|
|
116
|
+
if (rafId != null) return;
|
|
117
|
+
rafId = requestAnimationFrame(() => {
|
|
118
|
+
rafId = null;
|
|
119
|
+
reposition();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Keyboard access. The reparent into <body> above is what broke it: the panel emits correct
|
|
124
|
+
// role="menu" / role="menuitem" markup, but once it lives at the end of <body> a Tab from the
|
|
125
|
+
// trigger goes to the NEXT HEADER BUTTON, not into the menu — so the app's primary Delete / Edit /
|
|
126
|
+
// Chat / Translate surface could be opened by keyboard and then not used. Nothing moved focus in,
|
|
127
|
+
// and only Escape was handled.
|
|
128
|
+
//
|
|
129
|
+
// Fixed here, once, rather than in AccountMenu and LeafActionMenu separately: both consumers use
|
|
130
|
+
// this action, so both inherit it, and a third would too.
|
|
131
|
+
const items = (): HTMLElement[] => Array.from(node.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])'));
|
|
132
|
+
|
|
133
|
+
function focusItem(i: number): void {
|
|
134
|
+
const list = items();
|
|
135
|
+
if (list.length === 0) return;
|
|
136
|
+
list[(i + list.length) % list.length].focus();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function onMenuKeydown(e: KeyboardEvent) {
|
|
140
|
+
const list = items();
|
|
141
|
+
if (list.length === 0) return;
|
|
142
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
143
|
+
if (e.key === "ArrowDown") { e.preventDefault(); focusItem(at + 1); }
|
|
144
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); focusItem(at - 1); }
|
|
145
|
+
else if (e.key === "Home") { e.preventDefault(); focusItem(0); }
|
|
146
|
+
else if (e.key === "End") { e.preventDefault(); focusItem(list.length - 1); }
|
|
147
|
+
else if (e.key === "Tab") {
|
|
148
|
+
// Trap: with the panel in <body>, letting Tab through would land the user in the page behind
|
|
149
|
+
// the menu with no way back, which is worse than not opening it.
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
focusItem(e.shiftKey ? at - 1 : at + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Send focus back where it came from.
|
|
157
|
+
*
|
|
158
|
+
* Mandatory, not a nicety: the panel is removed from <body> on close, so without this the browser
|
|
159
|
+
* has nowhere sensible to put focus and drops it to <body> — leaving a keyboard user at the top of
|
|
160
|
+
* the document after every menu action.
|
|
161
|
+
*/
|
|
162
|
+
function restoreFocus(): void {
|
|
163
|
+
if (node.contains(document.activeElement)) anchor.focus();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
node.addEventListener("keydown", onMenuKeydown);
|
|
167
|
+
|
|
168
|
+
if (open) { reposition(); focusItem(0); }
|
|
169
|
+
window.addEventListener("scroll", scheduleReposition, { capture: true, passive: true });
|
|
170
|
+
window.addEventListener("resize", scheduleReposition);
|
|
171
|
+
|
|
172
|
+
// Placement was computed ONCE, at mount, and revisited only if the user scrolled or resized.
|
|
173
|
+
// That made an under-measurement permanent rather than transient: measure the panel before its
|
|
174
|
+
// webfont has reflowed it, and placeMenu clamps against a height that is too small, parking the
|
|
175
|
+
// panel low enough that the real content lands past the bottom of the viewport. Playwright then
|
|
176
|
+
// reported the last menu item as "visible, enabled and stable" AND "outside of the viewport", and
|
|
177
|
+
// retried it for the full 30s test timeout — twice in CI, never reproducibly on an M3, because the
|
|
178
|
+
// measurement is only wrong when layout is slow enough to lose the race. Observing the panel closes
|
|
179
|
+
// the class rather than that one cause: whatever settles late
|
|
180
|
+
// — fonts, async content, a scrollbar appearing — re-places it on the next frame.
|
|
181
|
+
const sizeObserver = new ResizeObserver(scheduleReposition);
|
|
182
|
+
sizeObserver.observe(node);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
update(newParams: AnchoredMenuParams) {
|
|
186
|
+
anchor = newParams.anchor;
|
|
187
|
+
const wasOpen = open;
|
|
188
|
+
open = newParams.open;
|
|
189
|
+
if (open && !wasOpen) { reposition(); focusItem(0); }
|
|
190
|
+
if (!open && wasOpen) restoreFocus();
|
|
191
|
+
},
|
|
192
|
+
destroy() {
|
|
193
|
+
restoreFocus(); // before the node goes, or focus lands on <body>
|
|
194
|
+
node.removeEventListener("keydown", onMenuKeydown);
|
|
195
|
+
sizeObserver.disconnect();
|
|
196
|
+
window.removeEventListener("scroll", scheduleReposition, true);
|
|
197
|
+
window.removeEventListener("resize", scheduleReposition);
|
|
198
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
199
|
+
// The mount-time appendChild above reparents `node` into <body>, putting it outside the
|
|
200
|
+
// marker range Svelte tears the surrounding {#if} down by — so Svelte cannot remove it and
|
|
201
|
+
// the panel is orphaned there, still rendered at its last position with its last content and
|
|
202
|
+
// its menuitems still in the accessibility tree. That is how two "Delete" items could both
|
|
203
|
+
// resolve at once (menu-exclusivity.spec.ts) after deleting the rows owning them. Having
|
|
204
|
+
// appended the node, this action must remove it; `remove()` is a no-op if something already did.
|
|
205
|
+
node.remove();
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One shared "open the native file picker" registry, mirroring menu-registry.svelte.ts's singleton
|
|
2
|
+
// shape: a single hidden <input type=file> (AttachPicker.svelte, mounted once in App) is driven by
|
|
3
|
+
// whichever leaf's "Attach" action last called openAttachPicker, instead of every leaf owning its
|
|
4
|
+
// own hidden input.
|
|
5
|
+
export type AttachMode = "camera" | "library" | "files";
|
|
6
|
+
|
|
7
|
+
// Everything. A leaf's Attach is storage, not import: whatever you attach is kept, shown, and — if
|
|
8
|
+
// it is a PDF or a text file — read so LexiTar can quote it. Anything else simply attaches and sits
|
|
9
|
+
// there, which is strictly better than a file picker that greys the file out with no explanation.
|
|
10
|
+
//
|
|
11
|
+
// The narrow "image/*,application/pdf,.xlsx,.xls" this replaces meant a .docx, a .csv or a .heic
|
|
12
|
+
// could not even be SELECTED from any leaf. **Reports is the one surface that refuses a file** — and
|
|
13
|
+
// it refuses on what the document turns out to be (report-extract.ts's isMedicalReport gate), not on
|
|
14
|
+
// its extension, which is a judgement a file picker cannot make.
|
|
15
|
+
export const DEFAULT_ATTACH_ACCEPT = "*/*";
|
|
16
|
+
|
|
17
|
+
type Opener = (mode: AttachMode, onFiles: (files: File[]) => void, accept?: string) => void;
|
|
18
|
+
|
|
19
|
+
let opener: Opener | null = null;
|
|
20
|
+
|
|
21
|
+
export function registerAttachPicker(fn: Opener): void {
|
|
22
|
+
opener = fn;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function openAttachPicker(mode: AttachMode, onFiles: (files: File[]) => void, accept?: string): void {
|
|
26
|
+
opener?.(mode, onFiles, accept);
|
|
27
|
+
}
|
package/brand.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Domain-neutral half of the brand split: the {path,label} -> absolute-link derivation, usable
|
|
2
|
+
// by any consuming app's own brand constants. The LexiTar-specific values (PRODUCT_NAME,
|
|
3
|
+
// MEDICAL_DISCLAIMER, ...) stay in packages/brand, which is not a dependency any other app takes.
|
|
4
|
+
export type LegalLink = { path: string; label: string };
|
|
5
|
+
|
|
6
|
+
export function deriveLegalLinks(base: string, paths: LegalLink[]): { href: string; label: string }[] {
|
|
7
|
+
return paths.map((l) => ({ href: `${base}${l.path}`, label: l.label }));
|
|
8
|
+
}
|
package/menu-items.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The shape of one item in a LeafActionMenu, as a plain .ts module.
|
|
2
|
+
//
|
|
3
|
+
// Same reason as sidebar-rows.ts and sidebar-labels.ts: a type exported from a .svelte file is
|
|
4
|
+
// resolvable only by svelte-check, so declaring it inside LeafActionMenu.svelte put every pure
|
|
5
|
+
// module that builds menus (leaf-actions.ts, and through it every Node-side importer) beyond the
|
|
6
|
+
// reach of a plain `tsc`. The component keeps the menu; the shape lives here.
|
|
7
|
+
export interface LeafMenuItem {
|
|
8
|
+
// Defaults to `title ?? label` (a caller only needs to set this explicitly if two items in the
|
|
9
|
+
// same menu would otherwise share both).
|
|
10
|
+
key?: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
onClick: () => void;
|
|
14
|
+
danger?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
title?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The one shared "which popover is open" registry every menu-style popover
|
|
2
|
+
// (LeafActionMenu, AccountMenu) consults, so opening any one closes any other open one anywhere in
|
|
3
|
+
// the app. First true singleton runes module in this codebase (most other modules here are
|
|
4
|
+
// per-call-site factories, not singletons — this module's state is created once, at import time,
|
|
5
|
+
// and shared by every caller).
|
|
6
|
+
const state = $state<{ openId: string | null }>({ openId: null });
|
|
7
|
+
|
|
8
|
+
export const menuRegistry = {
|
|
9
|
+
isOpen(id: string): boolean {
|
|
10
|
+
return state.openId === id;
|
|
11
|
+
},
|
|
12
|
+
open(id: string): void {
|
|
13
|
+
state.openId = id;
|
|
14
|
+
},
|
|
15
|
+
close(id: string): void {
|
|
16
|
+
if (state.openId === id) state.openId = null;
|
|
17
|
+
},
|
|
18
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinytars/frame",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/tinytars/lexi",
|
|
10
|
+
"directory": "packages/frame"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/tinytars/lexi",
|
|
13
|
+
"bugs": "https://github.com/tinytars/lexi/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"svelte",
|
|
16
|
+
"auth",
|
|
17
|
+
"session",
|
|
18
|
+
"app-shell",
|
|
19
|
+
"encryption"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=19"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"*.ts",
|
|
26
|
+
"*.svelte"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
"./account-methods.svelte": "./account-methods.svelte.ts",
|
|
30
|
+
"./AccountMenu.svelte": "./AccountMenu.svelte",
|
|
31
|
+
"./anchored-menu.svelte": "./anchored-menu.svelte.ts",
|
|
32
|
+
"./attach-controller": "./attach-controller.ts",
|
|
33
|
+
"./AttachPicker.svelte": "./AttachPicker.svelte",
|
|
34
|
+
"./brand": "./brand.ts",
|
|
35
|
+
"./Diagnostics.svelte": "./Diagnostics.svelte",
|
|
36
|
+
"./ExportTab.svelte": "./ExportTab.svelte",
|
|
37
|
+
"./LeafActionMenu.svelte": "./LeafActionMenu.svelte",
|
|
38
|
+
"./LeafCard.svelte": "./LeafCard.svelte",
|
|
39
|
+
"./LoginScreen.svelte": "./LoginScreen.svelte",
|
|
40
|
+
"./menu-items": "./menu-items.ts",
|
|
41
|
+
"./menu-registry.svelte": "./menu-registry.svelte.ts",
|
|
42
|
+
"./Onboarding.svelte": "./Onboarding.svelte",
|
|
43
|
+
"./recovery-controller.svelte": "./recovery-controller.svelte.ts",
|
|
44
|
+
"./roster-session.svelte": "./roster-session.svelte.ts",
|
|
45
|
+
"./support-access.svelte": "./support-access.svelte.ts",
|
|
46
|
+
"./vault-principals.svelte": "./vault-principals.svelte.ts",
|
|
47
|
+
"./vault-session.svelte": "./vault-session.svelte.ts",
|
|
48
|
+
"./VisibilitySettings.svelte": "./VisibilitySettings.svelte"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"typecheck": "svelte-check --tsconfig ./tsconfig.json --threshold error"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@tinytars/vault": "^0.1.15"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"svelte": "^5.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@tsconfig/svelte": "^5.0.8",
|
|
61
|
+
"svelte": "^5.55.5",
|
|
62
|
+
"svelte-check": "^4.4.8",
|
|
63
|
+
"typescript": "~6.0.2"
|
|
64
|
+
}
|
|
65
|
+
}
|