@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.
- package/dist/sdk/client/create-flow.js +3 -5
- package/dist/sdk/client/dialog-host.js +13 -1
- package/dist/sdk/client/flow-widget.js +6 -1
- package/dist/sdk/client/focus-session.d.ts +18 -0
- package/dist/sdk/client/focus-session.js +140 -0
- package/dist/sdk/client/focus.d.ts +11 -0
- package/dist/sdk/client/focus.js +30 -0
- package/dist/sdk/client/iframe-host.d.ts +5 -3
- package/dist/sdk/client/iframe-host.js +6 -3
- package/dist/sdk/client/index.d.ts +2 -1
- package/dist/sdk/client/index.js +1 -0
- package/dist/sdk/client/json-api.d.ts +1 -1
- package/dist/sdk/client/json-api.js +3 -1
- package/dist/sdk/client/open-profile.js +7 -3
- package/dist/sdk/client/overlay-focus.d.ts +1 -0
- package/dist/sdk/client/overlay-focus.js +24 -0
- package/dist/sdk/client/profile-button.js +9 -4
- package/dist/sdk/client/rooms.js +7 -26
- package/dist/sdk/client/study-search.d.ts +20 -0
- package/dist/sdk/client/study-search.js +116 -0
- package/dist/sdk/react/use-cooldown.d.ts +1 -0
- package/dist/sdk/react/use-cooldown.js +12 -0
- package/dist/sdk/types/account-data.d.ts +269 -0
- package/dist/sdk/types/account-data.js +0 -0
- package/dist/sdk/types/auth.d.ts +12 -0
- package/dist/sdk/types/dialog.d.ts +25 -0
- package/dist/sdk/types/events.d.ts +5 -1
- package/dist/sdk/types/focus.d.ts +109 -0
- package/dist/sdk/types/focus.js +0 -0
- package/dist/sdk/types/index.d.ts +8 -3
- package/dist/sdk/types/landing.d.ts +9 -0
- package/dist/sdk/types/landing.js +0 -0
- package/dist/sdk/types/otp.d.ts +20 -0
- package/dist/sdk/types/otp.js +0 -0
- package/dist/sdk/types/sdk.d.ts +1 -0
- package/dist/sdk/types/settings.d.ts +162 -0
- package/dist/sdk/types/settings.js +0 -0
- package/dist/sdk/types/xp.d.ts +8 -0
- 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).
|
|
20
|
-
*
|
|
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).
|
|
25
|
-
*
|
|
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";
|
package/dist/sdk/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/sdk/client/rooms.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|