@flow-industries/id 0.21.1 → 0.22.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.
Files changed (39) hide show
  1. package/dist/sdk/client/create-flow.js +3 -5
  2. package/dist/sdk/client/dialog-host.js +13 -1
  3. package/dist/sdk/client/flow-widget.js +6 -1
  4. package/dist/sdk/client/focus-session.d.ts +18 -0
  5. package/dist/sdk/client/focus-session.js +140 -0
  6. package/dist/sdk/client/focus.d.ts +11 -0
  7. package/dist/sdk/client/focus.js +30 -0
  8. package/dist/sdk/client/iframe-host.d.ts +5 -3
  9. package/dist/sdk/client/iframe-host.js +6 -3
  10. package/dist/sdk/client/index.d.ts +2 -1
  11. package/dist/sdk/client/index.js +1 -0
  12. package/dist/sdk/client/json-api.d.ts +1 -1
  13. package/dist/sdk/client/json-api.js +3 -1
  14. package/dist/sdk/client/open-profile.js +7 -3
  15. package/dist/sdk/client/overlay-focus.d.ts +1 -0
  16. package/dist/sdk/client/overlay-focus.js +24 -0
  17. package/dist/sdk/client/profile-button.js +9 -4
  18. package/dist/sdk/client/rooms.js +7 -26
  19. package/dist/sdk/client/study-search.d.ts +20 -0
  20. package/dist/sdk/client/study-search.js +116 -0
  21. package/dist/sdk/react/use-cooldown.d.ts +1 -0
  22. package/dist/sdk/react/use-cooldown.js +12 -0
  23. package/dist/sdk/types/account-data.d.ts +269 -0
  24. package/dist/sdk/types/account-data.js +0 -0
  25. package/dist/sdk/types/auth.d.ts +12 -0
  26. package/dist/sdk/types/dialog.d.ts +25 -0
  27. package/dist/sdk/types/events.d.ts +5 -1
  28. package/dist/sdk/types/focus.d.ts +109 -0
  29. package/dist/sdk/types/focus.js +0 -0
  30. package/dist/sdk/types/index.d.ts +8 -3
  31. package/dist/sdk/types/landing.d.ts +9 -0
  32. package/dist/sdk/types/landing.js +0 -0
  33. package/dist/sdk/types/otp.d.ts +20 -0
  34. package/dist/sdk/types/otp.js +0 -0
  35. package/dist/sdk/types/sdk.d.ts +1 -0
  36. package/dist/sdk/types/settings.d.ts +162 -0
  37. package/dist/sdk/types/settings.js +0 -0
  38. package/dist/sdk/types/xp.d.ts +8 -0
  39. package/package.json +6 -2
@@ -13,11 +13,8 @@ import { createStore, initialFlowState } from "./store";
13
13
  * see the result instead of racing it. Falls back to running `fn` directly
14
14
  * where Web Locks is unavailable.
15
15
  */
16
- function withOriginLock(name, fn) {
17
- const locks =
18
- /* SAFETY: the SDK stashes its singleton on globalThis so two bundles of it share one
19
- instance; the property is namespaced and written only here. */
20
- globalThis.navigator?.locks;
16
+ async function withOriginLock(name, fn) {
17
+ const locks = globalThis.navigator?.locks;
21
18
  if (locks?.request)
22
19
  return locks.request(name, fn);
23
20
  return fn();
@@ -362,6 +359,7 @@ export function createFlow(options = {}) {
362
359
  */
363
360
  async function login(loginOpts = {}) {
364
361
  const dialogHost = getDialog();
362
+ dialogHost.captureFocus();
365
363
  let accessKeyModule;
366
364
  let accessKeyPrep;
367
365
  let extraCapabilities;
@@ -1,4 +1,5 @@
1
1
  import { bridgeToWindow, makeIframe } from "./iframe-host";
2
+ import { setOverlayFocus } from "./overlay-focus";
2
3
  const HIDDEN_STYLE = {
3
4
  position: "fixed",
4
5
  inset: "0",
@@ -35,6 +36,12 @@ export function createDialogHost(options) {
35
36
  const { host, container = document.body } = options;
36
37
  let iframe = null;
37
38
  let messenger = null;
39
+ let opener = null;
40
+ function captureFocus() {
41
+ const active = document.activeElement;
42
+ if (active instanceof HTMLElement && active !== iframe)
43
+ opener = active;
44
+ }
38
45
  const pending = new Map();
39
46
  /**
40
47
  * Mounts the iframe and wires up the postMessage bridge. Idempotent — safe
@@ -43,8 +50,9 @@ export function createDialogHost(options) {
43
50
  function ensureFrame() {
44
51
  if (iframe)
45
52
  return;
46
- iframe = makeIframe(`${host}`);
53
+ iframe = makeIframe(`${host}`, "Flow ID");
47
54
  iframe.dataset.flowId = "";
55
+ iframe.inert = true;
48
56
  Object.assign(iframe.style, HIDDEN_STYLE);
49
57
  container.appendChild(iframe);
50
58
  // Bind the bridge to this iframe's own window — a page may host more than
@@ -109,6 +117,8 @@ export function createDialogHost(options) {
109
117
  if (!iframe)
110
118
  return;
111
119
  Object.assign(iframe.style, VISIBLE_STYLE);
120
+ setOverlayFocus(iframe, true, opener);
121
+ opener = null;
112
122
  // The dialog owns its open/close animation and mounts the overlay (playing
113
123
  // the enter animation) on this signal — same mechanism as the profile
114
124
  // widget, so every dialog appears and disappears identically.
@@ -124,6 +134,7 @@ export function createDialogHost(options) {
124
134
  // requestAnimationFrame, making the close (and the next open) skip straight
125
135
  // to the end. While hidden the overlay is transparent and click-through.
126
136
  iframe.style.pointerEvents = "none";
137
+ setOverlayFocus(iframe, false);
127
138
  void (
128
139
  // SAFETY: the payload map types this topic; the dialog reads exactly these fields.
129
140
  messenger?.send("__internal", { type: "dialog-hidden" }));
@@ -190,6 +201,7 @@ export function createDialogHost(options) {
190
201
  return dispatchRequest(method, params);
191
202
  }
192
203
  return {
204
+ captureFocus,
193
205
  open,
194
206
  close,
195
207
  destroy,
@@ -11,6 +11,11 @@ const WIDGET_ROUTE = {
11
11
  xp: "/dialog/xp-widget",
12
12
  "action-timer": "/dialog/action-timer",
13
13
  };
14
+ // `satisfies` the full widget union so a new widget cannot ship untitled.
15
+ const WIDGET_TITLE = {
16
+ xp: "Flow ID XP",
17
+ "action-timer": "Flow ID action timer",
18
+ };
14
19
  /**
15
20
  * Mounts one of the inline Flow ID widgets — the XP strip, the action timer —
16
21
  * as a transparent iframe the embedder positions and sizes itself.
@@ -42,7 +47,7 @@ export function createFlowWidget(options) {
42
47
  const hostOrigin = new URL(host).origin;
43
48
  let theme = options.theme ?? "light dark";
44
49
  const route = WIDGET_ROUTE[options.widget];
45
- const frame = makeIframe(`${host}${route}`);
50
+ const frame = makeIframe(`${host}${route}`, WIDGET_TITLE[options.widget]);
46
51
  frame.style.background = "transparent";
47
52
  const container = options.container ?? document.body;
48
53
  // Appended before bridging: `contentWindow` is null until the frame is in the
@@ -0,0 +1,18 @@
1
+ import type { ActiveAction, ClockAnchor, FocusSessionEvent, FocusSessionState } from "../types";
2
+ /** A re-read only replaces the anchor when it moves the display by more than
3
+ * this, so request timing never makes the clock jitter. */
4
+ export declare const REANCHOR_TOLERANCE_SECONDS = 2;
5
+ export declare function anchorFor(active: ActiveAction, readAt: number): ClockAnchor;
6
+ export declare function elapsedSeconds(anchor: ClockAnchor, now: number): number;
7
+ /** Whether a fresh server sample should replace the current anchor: yes when
8
+ * the pause state flipped or the server disagrees with the extrapolated clock
9
+ * by more than the tolerance (another surface paused, a replaced session). */
10
+ export declare function shouldReanchor(current: ClockAnchor, next: ActiveAction, now: number): boolean;
11
+ /** `mm:ss`, rolling into `h:mm:ss` past an hour. */
12
+ export declare function formatElapsed(seconds: number): string;
13
+ export declare const INITIAL_FOCUS_STATE: FocusSessionState;
14
+ export declare function focusSessionReducer(state: FocusSessionState, event: FocusSessionEvent): FocusSessionState;
15
+ /** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
16
+ * subject is attached. `fallback` covers a source the catalog no longer
17
+ * labels. */
18
+ export declare function actionHeadline(action: Pick<ActiveAction, "label" | "source" | "subject">, fallback: string): string;
@@ -0,0 +1,140 @@
1
+ /** A re-read only replaces the anchor when it moves the display by more than
2
+ * this, so request timing never makes the clock jitter. */
3
+ export const REANCHOR_TOLERANCE_SECONDS = 2;
4
+ export function anchorFor(active, readAt) {
5
+ return {
6
+ accruedSeconds: active.accruedSeconds,
7
+ paused: active.paused,
8
+ readAt,
9
+ };
10
+ }
11
+ export function elapsedSeconds(anchor, now) {
12
+ if (anchor.paused)
13
+ return anchor.accruedSeconds;
14
+ return anchor.accruedSeconds + Math.max(0, now - anchor.readAt) / 1000;
15
+ }
16
+ /** Whether a fresh server sample should replace the current anchor: yes when
17
+ * the pause state flipped or the server disagrees with the extrapolated clock
18
+ * by more than the tolerance (another surface paused, a replaced session). */
19
+ export function shouldReanchor(current, next, now) {
20
+ if (current.paused !== next.paused)
21
+ return true;
22
+ return (Math.abs(elapsedSeconds(current, now) - next.accruedSeconds) >
23
+ REANCHOR_TOLERANCE_SECONDS);
24
+ }
25
+ /** `mm:ss`, rolling into `h:mm:ss` past an hour. */
26
+ export function formatElapsed(seconds) {
27
+ const whole = Math.max(0, Math.floor(seconds));
28
+ const hours = Math.floor(whole / 3600);
29
+ const minutes = Math.floor((whole % 3600) / 60);
30
+ const secs = whole % 60;
31
+ const mmss = `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
32
+ return hours > 0 ? `${hours}:${mmss}` : mmss;
33
+ }
34
+ export const INITIAL_FOCUS_STATE = {
35
+ phase: "unknown",
36
+ active: null,
37
+ anchor: null,
38
+ finished: null,
39
+ conflict: null,
40
+ busy: false,
41
+ failed: false,
42
+ };
43
+ function withActive(state, active, at) {
44
+ const sameSession = state.active?.sessionId === active.sessionId;
45
+ const anchor = sameSession && state.anchor && !shouldReanchor(state.anchor, active, at)
46
+ ? state.anchor
47
+ : anchorFor(active, at);
48
+ return {
49
+ ...state,
50
+ phase: "active",
51
+ active,
52
+ anchor,
53
+ conflict: null,
54
+ failed: false,
55
+ };
56
+ }
57
+ export function focusSessionReducer(state, event) {
58
+ switch (event.type) {
59
+ case "read": {
60
+ if (event.active === null) {
61
+ return {
62
+ ...state,
63
+ phase: "idle",
64
+ active: null,
65
+ anchor: null,
66
+ failed: false,
67
+ };
68
+ }
69
+ return withActive(state, event.active, event.at);
70
+ }
71
+ case "read-failed":
72
+ return { ...state, failed: true };
73
+ case "request":
74
+ return { ...state, busy: true, failed: false };
75
+ case "request-failed":
76
+ return { ...state, busy: false, failed: true };
77
+ case "started":
78
+ return {
79
+ ...withActive(state, event.session, event.at),
80
+ anchor: anchorFor(event.session, event.at),
81
+ busy: false,
82
+ finished: null,
83
+ };
84
+ case "conflict":
85
+ return { ...state, busy: false, conflict: event.active };
86
+ case "paused": {
87
+ if (!state.active || !state.anchor)
88
+ return { ...state, busy: false };
89
+ const frozen = elapsedSeconds(state.anchor, event.at);
90
+ return {
91
+ ...state,
92
+ busy: false,
93
+ active: { ...state.active, paused: true, accruedSeconds: frozen },
94
+ anchor: { accruedSeconds: frozen, paused: true, readAt: event.at },
95
+ };
96
+ }
97
+ case "resumed": {
98
+ if (!state.active || !state.anchor)
99
+ return { ...state, busy: false };
100
+ return {
101
+ ...state,
102
+ busy: false,
103
+ active: { ...state.active, paused: false },
104
+ anchor: { ...state.anchor, paused: false, readAt: event.at },
105
+ };
106
+ }
107
+ case "finished": {
108
+ const finished = state.active
109
+ ? {
110
+ source: state.active.source,
111
+ label: state.active.label,
112
+ subject: state.active.subject,
113
+ xpGranted: event.result.xpGranted,
114
+ durationSeconds: event.result.durationSeconds,
115
+ }
116
+ : null;
117
+ return {
118
+ ...state,
119
+ phase: "idle",
120
+ active: null,
121
+ anchor: null,
122
+ busy: false,
123
+ finished,
124
+ };
125
+ }
126
+ case "dismiss-finished":
127
+ return { ...state, finished: null };
128
+ case "dismiss-conflict":
129
+ return { ...state, conflict: null };
130
+ case "reset":
131
+ return INITIAL_FOCUS_STATE;
132
+ }
133
+ }
134
+ /** The pill and panel headline: "Meditate", or "Practice · Chinese" when a
135
+ * subject is attached. `fallback` covers a source the catalog no longer
136
+ * labels. */
137
+ export function actionHeadline(action, fallback) {
138
+ const label = action.label ?? fallback;
139
+ return action.subject ? `${label} · ${action.subject.name}` : label;
140
+ }
@@ -0,0 +1,11 @@
1
+ import type { FocusApi, UserActionSurface } from "../types";
2
+ export type { ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionTransitionKind, ActiveAction, CatalogFieldRef, ClockAnchor, CreateStudySubjectInput, FinishActionRequest, FinishActionResponse, FinishedSummary, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, StartActionResult, StartFocusRequest, StudyCatalog, StudyCategory, StudyField, StudyRange, StudySearchHit, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRemovalResponse, StudySubjectResponse, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, } from "../types";
3
+ export { estimateActionXp, flowMultiplier } from "../xp/curve";
4
+ export { ActionsRequestError, isActiveSessionConflict } from "./actions";
5
+ export * from "./focus-session";
6
+ export { StudyRequestError } from "./study";
7
+ export * from "./study-search";
8
+ export declare const MEDITATION_SOURCE = "game.action.meditation";
9
+ export declare const STUDY_SOURCE = "action.study";
10
+ /** Compose action and study requests using the host's credentials and surface. */
11
+ export declare function createFocusApi(host: string, getToken: () => Promise<string | null>, surface: UserActionSurface): FocusApi;
@@ -0,0 +1,30 @@
1
+ import { createActionsApi } from "./actions";
2
+ import { createStudyApi } from "./study";
3
+ export { estimateActionXp, flowMultiplier } from "../xp/curve";
4
+ export { ActionsRequestError, isActiveSessionConflict } from "./actions";
5
+ export * from "./focus-session";
6
+ export { StudyRequestError } from "./study";
7
+ export * from "./study-search";
8
+ export const MEDITATION_SOURCE = "game.action.meditation";
9
+ export const STUDY_SOURCE = "action.study";
10
+ /** Compose action and study requests using the host's credentials and surface. */
11
+ export function createFocusApi(host, getToken, surface) {
12
+ const actions = createActionsApi(host, getToken);
13
+ const study = createStudyApi(host, getToken);
14
+ return {
15
+ catalog: actions.catalog,
16
+ active: actions.me,
17
+ start(request) {
18
+ return actions.start({ ...request, surface });
19
+ },
20
+ event: actions.event,
21
+ finish: actions.finish,
22
+ studyCatalog: study.catalog,
23
+ subjects: study.subjects,
24
+ createSubject: study.createSubject,
25
+ updateSubject: study.updateSubject,
26
+ deleteSubject: study.deleteSubject,
27
+ summary: study.summary,
28
+ sessions: study.sessions,
29
+ };
30
+ }
@@ -16,10 +16,12 @@ export declare const OVERLAY_STYLE: Partial<CSSStyleDeclaration>;
16
16
  /**
17
17
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
18
18
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
19
- * theme is applied to nested card wrappers, not the iframe). The caller owns
20
- * positioning/visibility and any `data-*` marker.
19
+ * theme is applied to nested card wrappers, not the iframe). `title` is the
20
+ * accessible name screen readers announce for the frame (WCAG 4.1.2), so every
21
+ * Flow surface must say what it is. The caller owns positioning/visibility and
22
+ * any `data-*` marker.
21
23
  */
22
- export declare function makeIframe(src: string): HTMLIFrameElement;
24
+ export declare function makeIframe(src: string, title: string): HTMLIFrameElement;
23
25
  /**
24
26
  * Builds a postMessage bridge to a child window (iframe `contentWindow` or
25
27
  * popup). Inbound is filtered by `source` so multiple same-origin Flow frames
@@ -21,12 +21,15 @@ export const OVERLAY_STYLE = {
21
21
  /**
22
22
  * Creates a Flow dialog iframe element: WebAuthn-permitted, borderless, and
23
23
  * `color-scheme: normal` so the iframe itself stays transparent (the rendered
24
- * theme is applied to nested card wrappers, not the iframe). The caller owns
25
- * positioning/visibility and any `data-*` marker.
24
+ * theme is applied to nested card wrappers, not the iframe). `title` is the
25
+ * accessible name screen readers announce for the frame (WCAG 4.1.2), so every
26
+ * Flow surface must say what it is. The caller owns positioning/visibility and
27
+ * any `data-*` marker.
26
28
  */
27
- export function makeIframe(src) {
29
+ export function makeIframe(src, title) {
28
30
  const frame = document.createElement("iframe");
29
31
  frame.src = src;
32
+ frame.title = title;
30
33
  frame.allow = IFRAME_ALLOW;
31
34
  frame.style.border = "none";
32
35
  frame.style.colorScheme = "normal";
@@ -1,10 +1,11 @@
1
1
  export { defaultIdHost, isLocalHostname } from "../id-host";
2
- export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
2
+ export type { AccessKeyOptions, ActionCatalogEntry, ActionCatalogResponse, ActionCurve, ActionEventRequest, ActionEventResponse, ActionSubjectRef, ActionSurface, ActionsApi, ActionTransitionKind, ActiveAction, ActiveActionResponse, ActiveSessionConflict, AdditionalSession, Address, CatalogFieldRef, ClockAnchor, ConnectCapabilities, ConnectResponse, CreateFlowOptions, CreateStudySubjectInput, CurrentAction, DialogHost, FinishActionRequest, FinishActionResponse, FinishedSummary, Flow, FlowCredential, FlowSessionState, FlowState, FlowUser, FlowWidgetHandle, FlowWidgetName, FocusApi, FocusPhase, FocusSessionEvent, FocusSessionState, ListStudySessionsOptions, ListStudySubjectsOptions, LoginOptions, MethodName, MountFlowWidgetOptions, MountProfileOptions, OpenProfileOptions, ProfileButtonHandle, ProfilePosition, RoomMemberEntry, Session, StartActionInput, StartActionResponse, StartActionResult, StartFocusRequest, StudyApi, StudyCatalog, StudyCategory, StudyCategoryTotals, StudyField, StudyRange, StudySearchHit, StudySessionEntry, StudySessionPage, StudySubject, StudySubjectList, StudySubjectRefusal, StudySubjectRemovalResponse, StudySubjectResponse, StudySubjectTotals, StudySummary, UpdateStudySubjectInput, UserActionSurface, XpEstimateContext, XpMode, } from "../types";
3
3
  export { consistencyCurve, estimateActionXp, FLOW_SCORE_MAX, flowMultiplier, } from "../xp/curve";
4
4
  export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from "./actions";
5
5
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
6
6
  export { createDialogHost } from "./dialog-host";
7
7
  export { createFlowWidget } from "./flow-widget";
8
+ export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
8
9
  export { METHODS } from "./methods";
9
10
  export { closeProfile, openProfile } from "./open-profile";
10
11
  export { createProfileButton } from "./profile-button";
@@ -4,6 +4,7 @@ export { ActionsRequestError, createActionsApi, isActiveSessionConflict, } from
4
4
  export { createFlow, getFlow, requireFlow, resetFlow } from "./create-flow";
5
5
  export { createDialogHost } from "./dialog-host";
6
6
  export { createFlowWidget } from "./flow-widget";
7
+ export { actionHeadline, anchorFor, catalogFieldIndex, createFocusApi, elapsedSeconds, fieldsCoveredBySubjects, focusSessionReducer, formatElapsed, hitLabel, INITIAL_FOCUS_STATE, MEDITATION_SOURCE, matchScore, normalizeSearchText, REANCHOR_TOLERANCE_SECONDS, recentSubjects, STUDY_SOURCE, searchStudy, shouldReanchor, } from "./focus";
7
8
  export { METHODS } from "./methods";
8
9
  export { closeProfile, openProfile } from "./open-profile";
9
10
  export { createProfileButton } from "./profile-button";
@@ -18,7 +18,7 @@ export type JsonApiError = (status: number, message: string, reason?: string) =>
18
18
  * raw status and payload for the few responses a client treats as values (a
19
19
  * start's 409); `request` is the common path that throws on any non-2xx.
20
20
  */
21
- export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError): {
21
+ export declare function createJsonApi(host: string, prefix: string, getToken: () => Promise<string | null>, makeError: JsonApiError, mapFailure?: (exchange: JsonExchange) => Error): {
22
22
  exchange: (path: string, options?: JsonRequestOptions) => Promise<JsonExchange>;
23
23
  request: <T>(path: string, options?: JsonRequestOptions) => Promise<T>;
24
24
  failure: (exchange: JsonExchange) => Error;
@@ -11,10 +11,12 @@ const failureSchema = z.object({
11
11
  * raw status and payload for the few responses a client treats as values (a
12
12
  * start's 409); `request` is the common path that throws on any non-2xx.
13
13
  */
14
- export function createJsonApi(host, prefix, getToken, makeError) {
14
+ export function createJsonApi(host, prefix, getToken, makeError, mapFailure) {
15
15
  /** The error a non-2xx exchange throws: the route's own wording when it
16
16
  * sent one, and its machine code as `reason`. */
17
17
  function failure(exchange) {
18
+ if (mapFailure)
19
+ return mapFailure(exchange);
18
20
  const parsed = failureSchema.safeParse(exchange.payload);
19
21
  const { error, reason } = parsed.success ? parsed.data : {};
20
22
  return makeError(exchange.status, error ?? `Request failed (${exchange.status})`, reason ?? error);
@@ -2,6 +2,7 @@ import { resolveIdHost } from "../id-host";
2
2
  import { isValidUsername } from "../usernames";
3
3
  import { getFlow, requireFlow } from "./create-flow";
4
4
  import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
5
+ import { setOverlayFocus } from "./overlay-focus";
5
6
  // One overlay per page, kept for the page's lifetime. The alternative — a
6
7
  // handle per call site — would put a second fullscreen iframe over the host the
7
8
  // moment two parts of an app (a scoreboard and a chat line) both showed a
@@ -23,12 +24,15 @@ function createViewer(options) {
23
24
  let visible = false;
24
25
  let onClose;
25
26
  function applyHitTesting() {
26
- if (frame)
27
- frame.style.pointerEvents = ready && visible ? "auto" : "none";
27
+ if (!frame)
28
+ return;
29
+ frame.style.pointerEvents = ready && visible ? "auto" : "none";
30
+ setOverlayFocus(frame, ready && visible);
28
31
  }
29
32
  function mount(username) {
30
- const el = makeIframe(`${host}/dialog/`);
33
+ const el = makeIframe(`${host}/dialog/`, "Flow ID profile");
31
34
  el.dataset.flowProfileView = "";
35
+ el.inert = true;
32
36
  Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
33
37
  // Appended before bridging: `contentWindow` is null until the frame is in
34
38
  // the document, and the bridge is bound to that window.
@@ -0,0 +1 @@
1
+ export declare function setOverlayFocus(frame: HTMLIFrameElement, visible: boolean, opener?: HTMLElement | null): void;
@@ -0,0 +1,24 @@
1
+ const triggers = new WeakMap();
2
+ export function setOverlayFocus(frame, visible, opener) {
3
+ if (visible) {
4
+ if (triggers.has(frame))
5
+ return;
6
+ const active = opener ?? document.activeElement;
7
+ triggers.set(frame, active instanceof HTMLElement && active !== frame ? active : null);
8
+ frame.inert = false;
9
+ frame.focus();
10
+ return;
11
+ }
12
+ const restore = document.activeElement === frame;
13
+ frame.inert = true;
14
+ const trigger = triggers.get(frame);
15
+ triggers.delete(frame);
16
+ if (restore && trigger?.isConnected) {
17
+ requestAnimationFrame(() => {
18
+ if (trigger.isConnected &&
19
+ (document.activeElement === frame ||
20
+ document.activeElement === document.body))
21
+ trigger.focus();
22
+ });
23
+ }
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolveIdHost } from "../id-host";
2
2
  import { getFlow, requireFlow } from "./create-flow";
3
3
  import { answerTokenRequest, bridgeToWindow, hostIdentity, makeIframe, OVERLAY_STYLE, } from "./iframe-host";
4
+ import { setOverlayFocus } from "./overlay-focus";
4
5
  const POSITION_STYLE = {
5
6
  "top-right": { top: "0", right: "0" },
6
7
  "top-left": { top: "0", left: "0" },
@@ -46,8 +47,8 @@ export function createProfileButton(options) {
46
47
  createdContainer = el;
47
48
  container = el;
48
49
  }
49
- function makeFrame() {
50
- const frame = makeIframe(`${host}/dialog/`);
50
+ function makeFrame(title) {
51
+ const frame = makeIframe(`${host}/dialog/`, title);
51
52
  frame.dataset.flowProfile = "";
52
53
  frame.style.display = "block";
53
54
  return frame;
@@ -71,7 +72,7 @@ export function createProfileButton(options) {
71
72
  return bridge;
72
73
  }
73
74
  // ----- the persistent pill (stays mounted; sizes to its own content) -----
74
- const pill = makeFrame();
75
+ const pill = makeFrame("Flow ID profile button");
75
76
  let pillWidth = 0;
76
77
  let pillHeight = 0;
77
78
  function applyPillSize() {
@@ -105,7 +106,8 @@ export function createProfileButton(options) {
105
106
  function ensureDialog() {
106
107
  if (dialog)
107
108
  return;
108
- dialog = makeFrame();
109
+ dialog = makeFrame("Flow ID profile");
110
+ dialog.inert = true;
109
111
  Object.assign(dialog.style, OVERLAY_STYLE, {
110
112
  pointerEvents: "none",
111
113
  });
@@ -144,12 +146,14 @@ export function createProfileButton(options) {
144
146
  if (!dialog)
145
147
  return;
146
148
  dialog.style.pointerEvents = "auto";
149
+ setOverlayFocus(dialog, true);
147
150
  void dialogBridge?.send("__internal", { type: "dialog-shown" });
148
151
  }
149
152
  function hideDialog() {
150
153
  if (!dialog)
151
154
  return;
152
155
  dialog.style.pointerEvents = "none";
156
+ setOverlayFocus(dialog, false);
153
157
  void dialogBridge?.send("__internal", { type: "dialog-hidden" });
154
158
  }
155
159
  // ----- keep both iframes' identity in sync with the Flow session -----
@@ -186,6 +190,7 @@ export function createProfileButton(options) {
186
190
  });
187
191
  },
188
192
  destroy() {
193
+ hideDialog();
189
194
  unsubscribe();
190
195
  pillBridge.destroy();
191
196
  dialogBridge?.destroy();
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { createJsonApi } from "./json-api";
2
3
  /** Every rooms endpoint answers a failure as `{ error, reason? }`. */
3
4
  const failureSchema = z.object({
4
5
  error: z.string().optional(),
@@ -22,32 +23,12 @@ export class RoomsRequestError extends Error {
22
23
  * bearer and fail fast with a 401-shaped error when no session exists.
23
24
  */
24
25
  export function createRoomsApi(host, getToken) {
25
- async function request(path, options = {}) {
26
- const headers = {};
27
- const token = await getToken();
28
- if (token)
29
- headers.Authorization = `Bearer ${token}`;
30
- else if (options.authRequired) {
31
- throw new RoomsRequestError(401, "Sign in first");
32
- }
33
- if (options.body !== undefined)
34
- headers["Content-Type"] = "application/json";
35
- const res = await fetch(`${host}/api/rooms${path}`, {
36
- method: options.method ?? "GET",
37
- headers,
38
- body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
39
- });
40
- const payload = await res.json().catch(() => ({}));
41
- if (!res.ok) {
42
- const failure = failureSchema.safeParse(payload);
43
- throw new RoomsRequestError(res.status, failure.success && failure.data.error
44
- ? failure.data.error
45
- : `Rooms request failed (${res.status})`, failure.success ? failure.data.reason : undefined);
46
- }
47
- /* SAFETY: each caller names the response type of the rooms endpoint it just called; a
48
- non-ok status has already thrown above with the server's own wording. */
49
- return payload;
50
- }
26
+ const { request } = createJsonApi(host, "/api/rooms", getToken, (status, message, reason) => new RoomsRequestError(status, message, reason), ({ status, payload }) => {
27
+ const parsed = failureSchema.safeParse(payload);
28
+ return new RoomsRequestError(status, parsed.success && parsed.data.error
29
+ ? parsed.data.error
30
+ : `Rooms request failed (${status})`, parsed.success ? parsed.data.reason : undefined);
31
+ });
51
32
  const slugPath = (slug) => `/${encodeURIComponent(slug)}`;
52
33
  const act = (slug, action, body) => request(`${slugPath(slug)}/${action}`, {
53
34
  method: "POST",
@@ -0,0 +1,20 @@
1
+ import type { CatalogFieldRef, StudyCatalog, StudySearchHit, StudySubject } from "../types";
2
+ export declare function normalizeSearchText(text: string): string;
3
+ /** 4 exact, 3 prefix, 2 a later word's prefix, 1 substring, 0 no match.
4
+ * Both sides are normalized here so callers can pass raw labels. */
5
+ export declare function matchScore(query: string, candidate: string): number;
6
+ export declare function catalogFieldIndex(catalog: StudyCatalog): Map<string, CatalogFieldRef>;
7
+ /** Live subjects newest-used first; auth stamps `lastUsedAt` at creation, so
8
+ * a subject never studied sorts by when it was added. */
9
+ export declare function recentSubjects(subjects: readonly StudySubject[], limit?: number): StudySubject[];
10
+ /** Catalog fields the user already keeps under the field's own name are
11
+ * hidden from results — the subject hit covers them and picking the field
12
+ * again would only re-create the same subject. A renamed subject ("Math for
13
+ * my test" under Algebra) leaves the field visible for a second subject. */
14
+ export declare function fieldsCoveredBySubjects(subjects: readonly StudySubject[], index: Map<string, CatalogFieldRef>): Set<string>;
15
+ export declare function searchStudy(query: string, options: {
16
+ catalog: StudyCatalog;
17
+ subjects: readonly StudySubject[];
18
+ limit?: number;
19
+ }): StudySearchHit[];
20
+ export declare function hitLabel(hit: StudySearchHit): string;