@learncard/partner-connect 0.5.2 → 0.6.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/README.md +395 -83
- package/dist/index.d.ts +257 -25
- package/dist/partner-connect.esm.js +3294 -431
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +3295 -430
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +3294 -431
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +3 -2
- package/src/docs-snippets.test.ts +79 -0
- package/src/index.ts +496 -33
- package/src/mock-host.ts +2447 -523
- package/src/mock-mode.test.ts +1337 -7
- package/src/origin-resolution.test.ts +229 -0
- package/src/types.ts +130 -11
package/src/mock-host.ts
CHANGED
|
@@ -11,17 +11,54 @@
|
|
|
11
11
|
* import time and degrades to log-only behavior when the DOM is unavailable.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
canonicalConsentScopeString,
|
|
16
|
+
canonicalJsonString,
|
|
17
|
+
compileInlineTemplate,
|
|
18
|
+
encodeManifestForUrl,
|
|
19
|
+
needsConsentSetup,
|
|
20
|
+
normalizeConsentRequest,
|
|
21
|
+
renderCompiledTemplate,
|
|
22
|
+
validateInlineTemplate,
|
|
23
|
+
validateTemplateData,
|
|
24
|
+
} from '@learncard/partner-connect-core';
|
|
25
|
+
import type {
|
|
26
|
+
CapturedAppManifest,
|
|
27
|
+
CapturedConsentRecord,
|
|
28
|
+
CapturedTemplateRecord,
|
|
29
|
+
ConsentRequest,
|
|
30
|
+
InlineCredentialTemplate,
|
|
31
|
+
MockHostOptions,
|
|
32
|
+
} from './types';
|
|
33
|
+
import { PartnerConnectError } from './types';
|
|
15
34
|
|
|
16
35
|
const DEFAULT_DID = 'did:web:mock.learncard.app:user';
|
|
17
36
|
const DEFAULT_NAMESPACE = 'lc-mock';
|
|
37
|
+
const DEFAULT_PUBLISH_ORIGIN = 'https://learncard.app';
|
|
18
38
|
const MOCK_PREFIX = '[LearnCard SDK · MOCK]';
|
|
39
|
+
const PUBLISH_DISMISS_TTL_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* SDK-supplied context that is deliberately not part of the public
|
|
43
|
+
* {@link MockHostOptions} surface.
|
|
44
|
+
*/
|
|
45
|
+
export interface MockHostContext {
|
|
46
|
+
/**
|
|
47
|
+
* Whether the publish origin was chosen deliberately (an explicit
|
|
48
|
+
* `mockOptions.publishOrigin`, or an active override) rather than
|
|
49
|
+
* inferred. Suppresses the "point me at a local LearnCard" HUD hint.
|
|
50
|
+
*/
|
|
51
|
+
publishOriginPinned?: boolean;
|
|
52
|
+
}
|
|
19
53
|
|
|
20
54
|
interface ResolvedMockOptions {
|
|
21
55
|
ui: boolean;
|
|
22
56
|
log: boolean;
|
|
23
57
|
persist: boolean;
|
|
24
58
|
namespace: string;
|
|
59
|
+
appId?: string;
|
|
60
|
+
publishPrompt: boolean;
|
|
61
|
+
publishOrigin: string;
|
|
25
62
|
}
|
|
26
63
|
|
|
27
64
|
interface StoredCounter {
|
|
@@ -29,6 +66,12 @@ interface StoredCounter {
|
|
|
29
66
|
updatedAt: string;
|
|
30
67
|
}
|
|
31
68
|
|
|
69
|
+
type StoredManifestMap = Record<string, CapturedAppManifest>;
|
|
70
|
+
|
|
71
|
+
type StoredPublishDismissedAtMap = Record<string, string>;
|
|
72
|
+
|
|
73
|
+
type StoredHudCollapsedMap = Record<string, string>;
|
|
74
|
+
|
|
32
75
|
type MockStatus = 'pending' | 'claimed' | 'revoked';
|
|
33
76
|
|
|
34
77
|
interface MockCredential {
|
|
@@ -44,548 +87,2363 @@ interface MockCredential {
|
|
|
44
87
|
credential: unknown;
|
|
45
88
|
}
|
|
46
89
|
|
|
90
|
+
interface InlineTemplateSessionState {
|
|
91
|
+
canonicalTemplate: string;
|
|
92
|
+
version: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
47
95
|
interface TemplateQuery {
|
|
48
96
|
templateAlias?: unknown;
|
|
49
97
|
boostUri?: unknown;
|
|
50
98
|
}
|
|
51
99
|
|
|
52
|
-
type ToastTone = 'default' | 'positive';
|
|
100
|
+
type ToastTone = 'default' | 'positive' | 'publish' | 'warning';
|
|
53
101
|
|
|
54
102
|
/** A toast body is a list of plain strings and bold (`{ b }`) segments. */
|
|
55
103
|
type ToastSegment = string | { b: string };
|
|
56
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Line glyphs drawn on a 24×24 grid with `stroke="currentColor"`, so they
|
|
107
|
+
* inherit color and render identically everywhere (unlike emoji).
|
|
108
|
+
*/
|
|
109
|
+
const ICON_PATHS = {
|
|
110
|
+
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5"/>',
|
|
111
|
+
award: '<circle cx="12" cy="9" r="6"/><path d="M8.6 14 7 22l5-3 5 3-1.6-8"/>',
|
|
112
|
+
shield: '<path d="M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6z"/><path d="m9 12 2 2 4-4"/>',
|
|
113
|
+
open: '<path d="M7 17 17 7"/><path d="M8 7h9v9"/>',
|
|
114
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
|
115
|
+
send: '<path d="M21 3 10 14"/><path d="M21 3 14.5 21l-4.5-7-7-4.5z"/>',
|
|
116
|
+
sparkles:
|
|
117
|
+
'<path d="M12 3.5 13.9 9l5.6 2-5.6 2L12 18.5 10.1 13l-5.6-2 5.6-2z"/><path d="M19 3v4M17 5h4"/>',
|
|
118
|
+
sync: '<path d="M20 12a8 8 0 0 1-14.3 4.9M4 12a8 8 0 0 1 14.3-4.9"/><path d="M18.5 3v4h-4M5.5 21v-4h4"/>',
|
|
119
|
+
bell: '<path d="M6 9a6 6 0 1 1 12 0c0 6.5 2.5 8.5 2.5 8.5h-17S6 15.5 6 9"/><path d="M10.3 20.5a2 2 0 0 0 3.4 0"/>',
|
|
120
|
+
hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
|
|
121
|
+
users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c0-3.5 2.9-6 6.5-6s6.5 2.5 6.5 6"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7M21.5 20c0-2.8-1.6-4.9-4-5.7"/>',
|
|
122
|
+
publish: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
|
|
123
|
+
mark: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M7 10h6M7 14h4"/>',
|
|
124
|
+
copy: '<rect x="9" y="9" width="11" height="11" rx="2.5"/><path d="M5 15V6.5A2.5 2.5 0 0 1 7.5 4H15"/>',
|
|
125
|
+
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
|
126
|
+
close: '<path d="M17 7 7 17M7 7l10 10"/>',
|
|
127
|
+
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
|
128
|
+
} as const;
|
|
129
|
+
|
|
130
|
+
type IconName = keyof typeof ICON_PATHS;
|
|
131
|
+
|
|
57
132
|
interface ToastSpec {
|
|
58
|
-
icon:
|
|
133
|
+
icon: IconName;
|
|
59
134
|
segments: ToastSegment[];
|
|
135
|
+
title?: string;
|
|
60
136
|
tone?: ToastTone;
|
|
61
137
|
ttl?: number;
|
|
138
|
+
dismissible?: boolean;
|
|
139
|
+
persistent?: boolean;
|
|
140
|
+
action?: {
|
|
141
|
+
label: string;
|
|
142
|
+
href: string;
|
|
143
|
+
};
|
|
62
144
|
}
|
|
63
145
|
|
|
64
146
|
interface ActiveToast {
|
|
65
147
|
node: HTMLElement;
|
|
66
|
-
timeoutId: ReturnType<typeof setTimeout
|
|
148
|
+
timeoutId: ReturnType<typeof setTimeout> | null;
|
|
67
149
|
count: number;
|
|
68
150
|
countEl: HTMLElement;
|
|
151
|
+
ttl: number;
|
|
152
|
+
persistent: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface HudFeature {
|
|
156
|
+
icon: IconName;
|
|
157
|
+
label: string;
|
|
158
|
+
detail?: string;
|
|
159
|
+
needsSetup?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const MAX_VISIBLE_TOASTS = 3;
|
|
163
|
+
|
|
164
|
+
const PERMISSION_FEATURES: Record<string, { icon: IconName; label: string }> = {
|
|
165
|
+
request_identity: { icon: 'user', label: 'Sign in with LearnCard' },
|
|
166
|
+
send_credential: { icon: 'award', label: 'Award credentials' },
|
|
167
|
+
template_issuance: { icon: 'send', label: 'Send from your templates' },
|
|
168
|
+
request_consent: { icon: 'shield', label: 'Ask for permission' },
|
|
169
|
+
credential_search: { icon: 'search', label: 'Ask learners to share credentials' },
|
|
170
|
+
credential_by_id: { icon: 'search', label: 'Request a specific credential' },
|
|
171
|
+
launch_feature: { icon: 'open', label: 'Open LearnCard screens' },
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Practice-mode chrome is injected into arbitrary partner pages, so every rule
|
|
176
|
+
* is scoped under `.lc-mock-stack` / `.lc-mock-hud` and resets the properties
|
|
177
|
+
* host CSS most often overrides (button/link/list defaults, box-sizing).
|
|
178
|
+
*/
|
|
179
|
+
const MOCK_STYLES = `
|
|
180
|
+
@keyframes lc-mock-in { from { opacity: 0; transform: translateY(14px) scale(0.96); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } }
|
|
181
|
+
@keyframes lc-mock-in-top { from { opacity: 0; transform: translateY(-14px) scale(0.96); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } }
|
|
182
|
+
@keyframes lc-mock-out { to { opacity: 0; transform: scale(0.94); filter: blur(4px); } }
|
|
183
|
+
@keyframes lc-mock-pop { from { opacity: 0; transform: translateY(10px) scale(0.94); } to { opacity: 1; transform: none; } }
|
|
184
|
+
@keyframes lc-mock-bump { 40% { transform: scale(1.18); } }
|
|
185
|
+
@keyframes lc-mock-pulse { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); } 70%, 100% { box-shadow: 0 0 0 6px rgba(16,185,129,0); } }
|
|
186
|
+
|
|
187
|
+
.lc-mock-stack, .lc-mock-hud {
|
|
188
|
+
--lc-glass: rgba(255,255,255,0.72);
|
|
189
|
+
--lc-glass-solid: rgba(251,251,252,0.97);
|
|
190
|
+
--lc-group: rgba(255,255,255,0.66);
|
|
191
|
+
--lc-edge: rgba(255,255,255,0.7);
|
|
192
|
+
--lc-hairline: rgba(24,34,78,0.08);
|
|
193
|
+
--lc-fill: rgba(24,34,78,0.06);
|
|
194
|
+
--lc-fill-hover: rgba(24,34,78,0.1);
|
|
195
|
+
--lc-ink: #18224E;
|
|
196
|
+
--lc-ink-2: #52597A;
|
|
197
|
+
--lc-ink-3: #8B91A7;
|
|
198
|
+
--lc-solid: #18224E;
|
|
199
|
+
--lc-on-solid: #FFFFFF;
|
|
200
|
+
--lc-shadow: 0 0 0 0.5px rgba(24,34,78,0.1), 0 2px 8px rgba(24,34,78,0.06), 0 24px 56px -16px rgba(24,34,78,0.32);
|
|
201
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
202
|
+
font-size: 13px; line-height: 1.4; letter-spacing: -0.006em; text-align: left;
|
|
203
|
+
color: var(--lc-ink); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
204
|
+
position: fixed; z-index: 2147483647;
|
|
205
|
+
}
|
|
206
|
+
@media (prefers-color-scheme: dark) {
|
|
207
|
+
.lc-mock-stack, .lc-mock-hud {
|
|
208
|
+
--lc-glass: rgba(30,37,68,0.62);
|
|
209
|
+
--lc-glass-solid: rgba(30,37,68,0.97);
|
|
210
|
+
--lc-group: rgba(255,255,255,0.06);
|
|
211
|
+
--lc-edge: rgba(255,255,255,0.14);
|
|
212
|
+
--lc-hairline: rgba(255,255,255,0.08);
|
|
213
|
+
--lc-fill: rgba(255,255,255,0.1);
|
|
214
|
+
--lc-fill-hover: rgba(255,255,255,0.16);
|
|
215
|
+
--lc-ink: #FBFBFC;
|
|
216
|
+
--lc-ink-2: #C5C8D3;
|
|
217
|
+
--lc-ink-3: #A8ACBD;
|
|
218
|
+
--lc-solid: #FBFBFC;
|
|
219
|
+
--lc-on-solid: #18224E;
|
|
220
|
+
--lc-shadow: 0 0 0 0.5px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.2), 0 24px 56px -16px rgba(0,0,0,0.6);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
.lc-mock-stack *, .lc-mock-hud * { box-sizing: border-box; }
|
|
224
|
+
.lc-mock-stack svg, .lc-mock-hud svg { display: block; flex: none; }
|
|
225
|
+
.lc-mock-stack button, .lc-mock-hud button {
|
|
226
|
+
font: inherit; color: inherit; margin: 0; text-transform: none; letter-spacing: inherit;
|
|
227
|
+
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
|
|
228
|
+
}
|
|
229
|
+
.lc-mock-stack a, .lc-mock-hud a { -webkit-tap-highlight-color: transparent; }
|
|
230
|
+
.lc-mock-stack :focus-visible, .lc-mock-hud :focus-visible { outline: 2px solid #10B981; outline-offset: 2px; }
|
|
231
|
+
|
|
232
|
+
.lc-mock-glass {
|
|
233
|
+
background: var(--lc-glass);
|
|
234
|
+
-webkit-backdrop-filter: saturate(180%) blur(28px); backdrop-filter: saturate(180%) blur(28px);
|
|
235
|
+
border: 0.5px solid var(--lc-edge);
|
|
236
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), var(--lc-shadow);
|
|
237
|
+
}
|
|
238
|
+
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
|
|
239
|
+
.lc-mock-glass { background: var(--lc-glass-solid); }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.lc-mock-dot {
|
|
243
|
+
width: 7px; height: 7px; border-radius: 999px; background: #10B981; flex: none;
|
|
244
|
+
animation: lc-mock-pulse 2.4s ease-out infinite;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* Notices */
|
|
248
|
+
.lc-mock-stack {
|
|
249
|
+
bottom: max(20px, calc(env(safe-area-inset-bottom, 0px) + 12px));
|
|
250
|
+
right: max(20px, calc(env(safe-area-inset-right, 0px) + 12px));
|
|
251
|
+
width: min(360px, calc(100vw - 40px));
|
|
252
|
+
display: flex; flex-direction: column; gap: 10px; pointer-events: none;
|
|
69
253
|
}
|
|
254
|
+
.lc-mock-toast {
|
|
255
|
+
position: relative; pointer-events: auto; display: flex; align-items: flex-start; gap: 12px;
|
|
256
|
+
width: 100%; padding: 12px 14px 13px 12px; border-radius: 22px;
|
|
257
|
+
animation: lc-mock-in 460ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
|
|
258
|
+
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
259
|
+
}
|
|
260
|
+
.lc-mock-toast:hover { transform: translateY(-2px); }
|
|
261
|
+
.lc-mock-toast.lc-mock-out { animation: lc-mock-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards; pointer-events: none; }
|
|
262
|
+
.lc-mock-toast--hidden { display: none; }
|
|
263
|
+
.lc-mock-toast--publish { order: -1; padding: 14px 14px 14px 12px; }
|
|
264
|
+
.lc-mock-toast--dismissible .lc-mock-content { padding-right: 22px; }
|
|
265
|
+
|
|
266
|
+
.lc-mock-tile {
|
|
267
|
+
width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex: none;
|
|
268
|
+
color: #FFFFFF; background: linear-gradient(180deg, #353E64 0%, #18224E 100%);
|
|
269
|
+
box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.18), 0 1px 2px rgba(24,34,78,0.24);
|
|
270
|
+
}
|
|
271
|
+
.lc-mock-tile svg { width: 18px; height: 18px; }
|
|
272
|
+
.lc-mock-toast--positive .lc-mock-tile { background: linear-gradient(180deg, #34D399 0%, #059669 100%); }
|
|
273
|
+
.lc-mock-toast--warning .lc-mock-tile { background: linear-gradient(180deg, #FBBF24 0%, #D97706 100%); }
|
|
274
|
+
.lc-mock-hud-row-detail.is-setup { color: #B45309; font-weight: 500; }
|
|
275
|
+
.lc-mock-hud-setup-dot { width: 8px; height: 8px; margin: 0 4px; border-radius: 999px; background: #F59E0B; flex: none; }
|
|
276
|
+
|
|
277
|
+
.lc-mock-content { flex: 1; min-width: 0; }
|
|
278
|
+
.lc-mock-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 2px; font-size: 11.5px; font-weight: 600; color: var(--lc-ink-3); }
|
|
279
|
+
.lc-mock-chip { padding: 1px 7px; border-radius: 999px; background: var(--lc-fill); color: var(--lc-ink-2); font-size: 10.5px; font-weight: 600; }
|
|
280
|
+
.lc-mock-count {
|
|
281
|
+
margin-left: auto; min-width: 22px; padding: 1px 7px; border-radius: 999px; text-align: center;
|
|
282
|
+
background: var(--lc-solid); color: var(--lc-on-solid); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums;
|
|
283
|
+
}
|
|
284
|
+
.lc-mock-bump { animation: lc-mock-bump 260ms ease-out; }
|
|
285
|
+
.lc-mock-title { font-size: 14.5px; font-weight: 600; letter-spacing: -0.012em; margin: 1px 0 2px; }
|
|
286
|
+
.lc-mock-body { font-size: 13.5px; line-height: 1.42; color: var(--lc-ink); overflow-wrap: anywhere; white-space: pre-line; }
|
|
287
|
+
.lc-mock-body strong { font-weight: 600; }
|
|
288
|
+
.lc-mock-toast--publish .lc-mock-body { color: var(--lc-ink-2); }
|
|
289
|
+
|
|
290
|
+
.lc-mock-action {
|
|
291
|
+
display: inline-flex; align-items: center; gap: 6px; margin-top: 11px; padding: 9px 15px; border-radius: 999px;
|
|
292
|
+
background: var(--lc-solid); color: var(--lc-on-solid) !important; text-decoration: none !important;
|
|
293
|
+
font-size: 13px; font-weight: 600; line-height: 1;
|
|
294
|
+
transition: transform 160ms ease, opacity 160ms ease;
|
|
295
|
+
}
|
|
296
|
+
.lc-mock-action svg { width: 14px; height: 14px; }
|
|
297
|
+
.lc-mock-action:hover { opacity: 0.9; }
|
|
298
|
+
.lc-mock-action:active { transform: scale(0.97); }
|
|
299
|
+
|
|
300
|
+
.lc-mock-close {
|
|
301
|
+
position: absolute; top: 10px; right: 10px; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 999px;
|
|
302
|
+
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
303
|
+
background: var(--lc-fill); color: var(--lc-ink-2); transition: background 160ms ease, color 160ms ease;
|
|
304
|
+
}
|
|
305
|
+
.lc-mock-close svg { width: 13px; height: 13px; }
|
|
306
|
+
.lc-mock-close:hover { background: var(--lc-fill-hover); color: var(--lc-ink); }
|
|
307
|
+
|
|
308
|
+
/* Practice panel */
|
|
309
|
+
.lc-mock-hud {
|
|
310
|
+
left: max(20px, calc(env(safe-area-inset-left, 0px) + 12px));
|
|
311
|
+
bottom: max(20px, calc(env(safe-area-inset-bottom, 0px) + 12px));
|
|
312
|
+
}
|
|
313
|
+
.lc-mock-hud.lc-mock-animate > * { animation: lc-mock-pop 380ms cubic-bezier(0.22, 1, 0.36, 1) backwards; transform-origin: bottom left; }
|
|
314
|
+
|
|
315
|
+
.lc-mock-hud-pill {
|
|
316
|
+
display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 8px 0 6px; border-radius: 999px;
|
|
317
|
+
cursor: pointer; font-size: 13px; font-weight: 600; color: var(--lc-ink);
|
|
318
|
+
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
319
|
+
}
|
|
320
|
+
.lc-mock-hud-pill:hover { transform: translateY(-2px); }
|
|
321
|
+
.lc-mock-hud-pill:active { transform: scale(0.97); }
|
|
322
|
+
.lc-mock-hud-pill .lc-mock-dot { margin-left: 2px; }
|
|
323
|
+
.lc-mock-hud-mark {
|
|
324
|
+
width: 28px; height: 28px; border-radius: 999px; display: flex; align-items: center; justify-content: center; flex: none;
|
|
325
|
+
color: #FFFFFF; background: linear-gradient(180deg, #353E64 0%, #18224E 100%);
|
|
326
|
+
box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.18);
|
|
327
|
+
}
|
|
328
|
+
.lc-mock-hud-mark svg { width: 15px; height: 15px; }
|
|
329
|
+
.lc-mock-hud-mark--lg { width: 36px; height: 36px; border-radius: 11px; }
|
|
330
|
+
.lc-mock-hud-mark--lg svg { width: 19px; height: 19px; }
|
|
331
|
+
.lc-mock-hud-badge {
|
|
332
|
+
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center;
|
|
333
|
+
background: var(--lc-fill); color: var(--lc-ink-2); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.lc-mock-hud-card {
|
|
337
|
+
width: min(328px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto;
|
|
338
|
+
border-radius: 26px; padding: 8px 8px 10px; overscroll-behavior: contain;
|
|
339
|
+
}
|
|
340
|
+
.lc-mock-hud-header { display: flex; align-items: center; gap: 11px; padding: 6px 4px 12px 6px; }
|
|
341
|
+
.lc-mock-hud-heading { flex: 1; min-width: 0; }
|
|
342
|
+
.lc-mock-hud-title { display: flex; align-items: center; gap: 7px; font-size: 15px; font-weight: 600; letter-spacing: -0.014em; }
|
|
343
|
+
.lc-mock-hud-subtitle { font-size: 12px; color: var(--lc-ink-3); margin-top: 1px; }
|
|
344
|
+
.lc-mock-hud-minimize {
|
|
345
|
+
width: 30px; height: 30px; padding: 0; border: 0; border-radius: 999px; flex: none; cursor: pointer;
|
|
346
|
+
display: flex; align-items: center; justify-content: center;
|
|
347
|
+
background: var(--lc-fill); color: var(--lc-ink-2); transition: background 160ms ease, color 160ms ease;
|
|
348
|
+
}
|
|
349
|
+
.lc-mock-hud-minimize svg { width: 16px; height: 16px; }
|
|
350
|
+
.lc-mock-hud-minimize:hover { background: var(--lc-fill-hover); color: var(--lc-ink); }
|
|
351
|
+
|
|
352
|
+
.lc-mock-hud-group { background: var(--lc-group); border: 0.5px solid var(--lc-hairline); border-radius: 18px; }
|
|
353
|
+
.lc-mock-hud-app { display: flex; align-items: center; gap: 11px; padding: 10px 12px; }
|
|
354
|
+
.lc-mock-hud-avatar {
|
|
355
|
+
width: 36px; height: 36px; border-radius: 10px; flex: none; overflow: hidden;
|
|
356
|
+
display: flex; align-items: center; justify-content: center;
|
|
357
|
+
background: var(--lc-fill); color: var(--lc-ink-2); font-size: 15px; font-weight: 600;
|
|
358
|
+
}
|
|
359
|
+
.lc-mock-hud-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
360
|
+
.lc-mock-hud-app-name { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
361
|
+
|
|
362
|
+
.lc-mock-hud-section {
|
|
363
|
+
padding: 14px 12px 6px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--lc-ink-3);
|
|
364
|
+
}
|
|
365
|
+
.lc-mock-hud-list { list-style: none; margin: 0; padding: 0; max-height: min(40vh, 320px); overflow: auto; }
|
|
366
|
+
.lc-mock-hud-row { position: relative; display: flex; align-items: center; gap: 11px; padding: 9px 12px; margin: 0; }
|
|
367
|
+
.lc-mock-hud-row + .lc-mock-hud-row::before {
|
|
368
|
+
content: ''; position: absolute; top: 0; left: 50px; right: 0; height: 0.5px; background: var(--lc-hairline);
|
|
369
|
+
}
|
|
370
|
+
.lc-mock-hud-glyph {
|
|
371
|
+
width: 28px; height: 28px; border-radius: 8px; flex: none; display: flex; align-items: center; justify-content: center;
|
|
372
|
+
background: var(--lc-fill); color: var(--lc-ink);
|
|
373
|
+
}
|
|
374
|
+
.lc-mock-hud-glyph svg { width: 15px; height: 15px; }
|
|
375
|
+
.lc-mock-hud-row-text { flex: 1; min-width: 0; }
|
|
376
|
+
.lc-mock-hud-row-label { font-size: 13px; font-weight: 500; }
|
|
377
|
+
.lc-mock-hud-row-detail { font-size: 12px; color: var(--lc-ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
|
378
|
+
.lc-mock-hud-check { color: #10B981; flex: none; }
|
|
379
|
+
.lc-mock-hud-check svg { width: 16px; height: 16px; stroke-width: 2.4; }
|
|
380
|
+
.lc-mock-hud-empty { padding: 14px; font-size: 13px; color: var(--lc-ink-3); line-height: 1.45; }
|
|
381
|
+
|
|
382
|
+
.lc-mock-hud-footer { display: flex; gap: 8px; padding: 12px 2px 0; }
|
|
383
|
+
.lc-mock-hud-publish {
|
|
384
|
+
flex: 1; height: 42px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
385
|
+
background: var(--lc-solid); color: var(--lc-on-solid) !important; text-decoration: none !important;
|
|
386
|
+
font-size: 14px; font-weight: 600; transition: transform 160ms ease, opacity 160ms ease;
|
|
387
|
+
}
|
|
388
|
+
.lc-mock-hud-publish svg { width: 15px; height: 15px; }
|
|
389
|
+
.lc-mock-hud-publish:hover { opacity: 0.9; }
|
|
390
|
+
.lc-mock-hud-publish:active { transform: scale(0.98); }
|
|
391
|
+
.lc-mock-hud-copy {
|
|
392
|
+
width: 42px; height: 42px; padding: 0; border: 0; border-radius: 999px; flex: none; cursor: pointer;
|
|
393
|
+
display: flex; align-items: center; justify-content: center;
|
|
394
|
+
background: var(--lc-fill); color: var(--lc-ink); transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
|
395
|
+
}
|
|
396
|
+
.lc-mock-hud-copy svg { width: 17px; height: 17px; }
|
|
397
|
+
.lc-mock-hud-copy:hover { background: var(--lc-fill-hover); }
|
|
398
|
+
.lc-mock-hud-copy:active { transform: scale(0.94); }
|
|
399
|
+
.lc-mock-hud-copy.is-copied { background: rgba(16,185,129,0.14); color: #059669; }
|
|
400
|
+
.lc-mock-hud .lc-mock-hud-reset { display: block; margin: 10px auto 0; border: 0; background: transparent; color: var(--lc-ink-3); font-size: 12px; cursor: pointer; }
|
|
401
|
+
.lc-mock-hud .lc-mock-hud-reset:hover { color: var(--lc-ink); }
|
|
402
|
+
.lc-mock-hud .lc-mock-hud-confirm { padding: 12px 8px; color: var(--lc-ink); }
|
|
403
|
+
.lc-mock-hud .lc-mock-hud-confirm p { color: var(--lc-ink-3); font-size: 13px; line-height: 1.5; }
|
|
404
|
+
.lc-mock-hud .lc-mock-hud-confirm-actions { display: flex; gap: 8px; margin-top: 16px; }
|
|
405
|
+
.lc-mock-hud .lc-mock-hud-confirm button { border-radius: 999px; padding: 10px 16px; border: 1px solid var(--lc-ink-3); background: transparent; color: var(--lc-ink); cursor: pointer; }
|
|
406
|
+
.lc-mock-hud .lc-mock-hud-confirm .lc-mock-hud-reset-confirm { background: #DC2626; color: white; border-color: #DC2626; }
|
|
407
|
+
.lc-mock-hud-publish-hint { padding: 10px 8px 0; font-size: 11.5px; line-height: 1.45; color: var(--lc-ink-3); overflow-wrap: anywhere; }
|
|
408
|
+
.lc-mock-hud-publish-hint code {
|
|
409
|
+
display: inline-block; max-width: 100%; margin-top: 3px; word-break: break-all;
|
|
410
|
+
font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; padding: 1px 5px; border-radius: 6px;
|
|
411
|
+
background: var(--lc-fill); color: var(--lc-ink-2);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
@media (max-width: 640px) {
|
|
415
|
+
.lc-mock-stack {
|
|
416
|
+
top: max(12px, calc(env(safe-area-inset-top, 0px) + 8px)); bottom: auto; left: 12px; right: 12px; width: auto;
|
|
417
|
+
flex-direction: column-reverse;
|
|
418
|
+
}
|
|
419
|
+
.lc-mock-toast { animation-name: lc-mock-in-top; }
|
|
420
|
+
.lc-mock-toast--older { display: none; }
|
|
421
|
+
.lc-mock-hud { left: 12px; bottom: max(12px, calc(env(safe-area-inset-bottom, 0px) + 8px)); }
|
|
422
|
+
.lc-mock-hud-card { width: calc(100vw - 24px); }
|
|
423
|
+
}
|
|
424
|
+
@media (prefers-reduced-motion: reduce) {
|
|
425
|
+
.lc-mock-stack *, .lc-mock-hud *, .lc-mock-hud.lc-mock-animate > * { animation: none !important; transition: none !important; }
|
|
426
|
+
}
|
|
427
|
+
`.trim();
|
|
70
428
|
|
|
71
429
|
const hasDocument = (): boolean =>
|
|
72
430
|
typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
73
431
|
|
|
432
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
433
|
+
|
|
434
|
+
/** Builds an inline SVG glyph. Paths are static constants, never user input. */
|
|
435
|
+
const createIcon = (name: IconName): SVGSVGElement => {
|
|
436
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
437
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
438
|
+
svg.setAttribute('fill', 'none');
|
|
439
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
440
|
+
svg.setAttribute('stroke-width', '1.9');
|
|
441
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
442
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
443
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
444
|
+
svg.innerHTML = ICON_PATHS[name];
|
|
445
|
+
return svg;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const createEl = <K extends keyof HTMLElementTagNameMap>(
|
|
449
|
+
tag: K,
|
|
450
|
+
className?: string,
|
|
451
|
+
text?: string
|
|
452
|
+
): HTMLElementTagNameMap[K] => {
|
|
453
|
+
const el = document.createElement(tag);
|
|
454
|
+
if (className) el.className = className;
|
|
455
|
+
if (text !== undefined) el.textContent = text;
|
|
456
|
+
return el;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
460
|
+
|
|
461
|
+
const readablePath = (path: string): string => path.split(/[?#]/)[0] || path;
|
|
462
|
+
|
|
463
|
+
const readHost = (url: string | undefined): string => {
|
|
464
|
+
if (!url) return '';
|
|
465
|
+
try {
|
|
466
|
+
return new URL(url).host;
|
|
467
|
+
} catch {
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const readableTemplateName = (record: CapturedTemplateRecord): string => {
|
|
473
|
+
const template = record.template as { name?: unknown } | undefined;
|
|
474
|
+
const name = template?.name;
|
|
475
|
+
return typeof name === 'string' && name.trim() && !name.includes('{{')
|
|
476
|
+
? name.trim()
|
|
477
|
+
: record.alias;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const MAX_APP_FINGERPRINT_LENGTH = 64;
|
|
481
|
+
|
|
482
|
+
const slugifyAppFingerprint = (value: string | undefined): string => {
|
|
483
|
+
if (!value) return 'default';
|
|
484
|
+
|
|
485
|
+
const slug = value
|
|
486
|
+
.trim()
|
|
487
|
+
.toLowerCase()
|
|
488
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
489
|
+
.replace(/-+/g, '-')
|
|
490
|
+
.replace(/^-|-$/g, '')
|
|
491
|
+
.slice(0, MAX_APP_FINGERPRINT_LENGTH)
|
|
492
|
+
.replace(/-$/g, '');
|
|
493
|
+
|
|
494
|
+
return slug || 'default';
|
|
495
|
+
};
|
|
496
|
+
|
|
74
497
|
const readAchievementName = (payload: unknown): string => {
|
|
75
498
|
if (!payload || typeof payload !== 'object') return 'a credential';
|
|
76
499
|
|
|
77
|
-
const record = payload as Record<string, unknown>;
|
|
500
|
+
const record = payload as Record<string, unknown>;
|
|
501
|
+
|
|
502
|
+
// Template-based issuance (APP_EVENT / send-credential).
|
|
503
|
+
if (typeof record.templateAlias === 'string' && record.templateAlias) {
|
|
504
|
+
const data = record.templateData;
|
|
505
|
+
if (data && typeof data === 'object') {
|
|
506
|
+
const fields = data as Record<string, unknown>;
|
|
507
|
+
const named =
|
|
508
|
+
fields.name ?? fields.achievementName ?? fields.courseName ?? fields.title;
|
|
509
|
+
if (typeof named === 'string' && named) return named;
|
|
510
|
+
}
|
|
511
|
+
return record.templateAlias;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Raw verifiable credential.
|
|
515
|
+
const credential = (record.credential ?? record) as Record<string, unknown>;
|
|
516
|
+
if (credential && typeof credential === 'object') {
|
|
517
|
+
const subject = credential.credentialSubject as Record<string, unknown> | undefined;
|
|
518
|
+
const achievement = subject?.achievement as Record<string, unknown> | undefined;
|
|
519
|
+
if (achievement && typeof achievement.name === 'string' && achievement.name) {
|
|
520
|
+
return achievement.name;
|
|
521
|
+
}
|
|
522
|
+
if (typeof credential.name === 'string' && credential.name) return credential.name;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return 'a credential';
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const readRenderedCredentialName = (credential: Record<string, unknown>): string => {
|
|
529
|
+
if (typeof credential.name === 'string' && credential.name) return credential.name;
|
|
530
|
+
|
|
531
|
+
const subject = credential.credentialSubject;
|
|
532
|
+
if (subject && typeof subject === 'object' && !Array.isArray(subject)) {
|
|
533
|
+
const achievement = (subject as Record<string, unknown>).achievement;
|
|
534
|
+
if (achievement && typeof achievement === 'object' && !Array.isArray(achievement)) {
|
|
535
|
+
const name = (achievement as Record<string, unknown>).name;
|
|
536
|
+
if (typeof name === 'string' && name) return name;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return 'a credential';
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const buildConsentScopeToastSegments = (payload: unknown): ToastSegment[] => {
|
|
544
|
+
const scopes = (payload as { scopes?: unknown } | undefined)?.scopes;
|
|
545
|
+
|
|
546
|
+
if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) {
|
|
547
|
+
return [
|
|
548
|
+
'In LearnCard, the learner would review this request. Approved automatically here.',
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const normalized = normalizeConsentRequest(scopes as ConsentRequest);
|
|
553
|
+
const readCategories = normalized.read.credentialCategories;
|
|
554
|
+
const personalFields = normalized.read.personalFields;
|
|
555
|
+
const writeCategories = normalized.write.credentialCategories;
|
|
556
|
+
const asks: ToastSegment[][] = [];
|
|
557
|
+
|
|
558
|
+
if (readCategories.length > 0) {
|
|
559
|
+
asks.push(['see their ', { b: readCategories.join(', ') }, ' credentials']);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (personalFields.length > 0) {
|
|
563
|
+
asks.push(['see their ', { b: personalFields.join(', ') }]);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (writeCategories.length > 0) {
|
|
567
|
+
asks.push(['add ', { b: writeCategories.join(', ') }, ' credentials']);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (asks.length === 0) {
|
|
571
|
+
return ['The learner would be asked for permission. Approved automatically here.'];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const parts: ToastSegment[] = ['The learner would be asked to let your app '];
|
|
575
|
+
asks.forEach((ask, index) => {
|
|
576
|
+
if (index > 0) parts.push(index === asks.length - 1 ? ' and ' : ', ');
|
|
577
|
+
parts.push(...ask);
|
|
578
|
+
});
|
|
579
|
+
parts.push('. Approved automatically here.');
|
|
580
|
+
|
|
581
|
+
return parts;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Simulates the LearnCard host for a single {@link PartnerConnect} instance.
|
|
586
|
+
* All public methods route through `handle`, keeping the mock behind the same
|
|
587
|
+
* `(action, payload)` contract the real host answers over `postMessage`.
|
|
588
|
+
*/
|
|
589
|
+
export class MockHost {
|
|
590
|
+
private readonly options: ResolvedMockOptions;
|
|
591
|
+
|
|
592
|
+
private readonly context: MockHostContext;
|
|
593
|
+
|
|
594
|
+
/** In-memory counter fallback used when persistence is off/unavailable. */
|
|
595
|
+
private readonly memoryCounters = new Map<string, StoredCounter>();
|
|
596
|
+
|
|
597
|
+
private readonly memoryManifests: StoredManifestMap = {};
|
|
598
|
+
|
|
599
|
+
private readonly memoryPublishDismissedAt: StoredPublishDismissedAtMap = {};
|
|
600
|
+
|
|
601
|
+
private readonly memoryHudCollapsed: StoredHudCollapsedMap = {};
|
|
602
|
+
|
|
603
|
+
private initialDocumentTitle: string | null | undefined;
|
|
604
|
+
private appFingerprint: string | undefined;
|
|
605
|
+
private memoryAppKey: string | undefined;
|
|
606
|
+
private iconSource: string | undefined;
|
|
607
|
+
private iconGeneration = 0;
|
|
608
|
+
private readonly iconCleanups = new Set<() => void>();
|
|
609
|
+
private confirmingReset = false;
|
|
610
|
+
|
|
611
|
+
/** Session-scoped credential store; reads reflect writes and seeds. */
|
|
612
|
+
private readonly credentials: MockCredential[] = [];
|
|
613
|
+
|
|
614
|
+
/** Session-scoped alias → inline template version state. */
|
|
615
|
+
private readonly inlineTemplateVersions = new Map<string, InlineTemplateSessionState>();
|
|
616
|
+
|
|
617
|
+
private readonly identity: { did: string; [key: string]: unknown };
|
|
618
|
+
|
|
619
|
+
/** DOM nodes injected for visual feedback, tracked for cleanup. */
|
|
620
|
+
private readonly domNodes = new Set<HTMLElement>();
|
|
621
|
+
|
|
622
|
+
/** The app's single AI Topic, created lazily by the first AI session. */
|
|
623
|
+
private aiTopic: { topicUri: string; topicCredentialUri: string } | null = null;
|
|
624
|
+
|
|
625
|
+
private styleEl: HTMLStyleElement | null = null;
|
|
626
|
+
private stackEl: HTMLElement | null = null;
|
|
627
|
+
private hudEl: HTMLElement | null = null;
|
|
628
|
+
private hudAnimateNext = true;
|
|
629
|
+
private readonly activeToasts = new Map<string, ActiveToast>();
|
|
630
|
+
/** Pending exit-animation timers, tracked so `destroy()` can cancel them. */
|
|
631
|
+
private readonly exitTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
632
|
+
private idSeq = 0;
|
|
633
|
+
private destroyed = false;
|
|
634
|
+
private publishPromptShown = false;
|
|
635
|
+
|
|
636
|
+
constructor(options?: MockHostOptions, context?: MockHostContext) {
|
|
637
|
+
this.context = { ...context };
|
|
638
|
+
this.options = {
|
|
639
|
+
ui: options?.ui ?? true,
|
|
640
|
+
log: options?.log ?? true,
|
|
641
|
+
persist: options?.persist ?? true,
|
|
642
|
+
namespace: options?.namespace || DEFAULT_NAMESPACE,
|
|
643
|
+
appId: options?.appId,
|
|
644
|
+
publishPrompt: options?.publishPrompt ?? true,
|
|
645
|
+
publishOrigin: options?.publishOrigin || DEFAULT_PUBLISH_ORIGIN,
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
this.identity = {
|
|
649
|
+
...(options?.identity ?? {}),
|
|
650
|
+
did: options?.identity?.did || options?.did || DEFAULT_DID,
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
for (const seed of options?.credentials ?? []) {
|
|
654
|
+
this.addCredential({
|
|
655
|
+
name: seed.name || seed.templateAlias || 'Mock Credential',
|
|
656
|
+
templateAlias: seed.templateAlias,
|
|
657
|
+
boostUri: seed.boostUri,
|
|
658
|
+
recipient: seed.recipient,
|
|
659
|
+
status: seed.status,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
for (const [key, value] of Object.entries(options?.counters ?? {})) {
|
|
664
|
+
if (this.readCounter(key) === undefined) this.writeCounter(key, value);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
this.announce();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Resolve a simulated response for one SDK request. Mirrors the real host:
|
|
672
|
+
* direct actions map 1:1, and `APP_EVENT` is dispatched by its `type`.
|
|
673
|
+
*/
|
|
674
|
+
public handle(action: string, payload?: unknown): Promise<unknown> {
|
|
675
|
+
if (this.destroyed) {
|
|
676
|
+
return Promise.reject(
|
|
677
|
+
new PartnerConnectError(
|
|
678
|
+
'SDK_DESTROYED',
|
|
679
|
+
'Mock host was destroyed before the request completed'
|
|
680
|
+
)
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
this.log(action, payload);
|
|
685
|
+
|
|
686
|
+
if (action === 'APP_EVENT') {
|
|
687
|
+
return this.handleAppEvent(payload as Record<string, unknown>);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const response = (() => {
|
|
691
|
+
switch (action) {
|
|
692
|
+
case 'REQUEST_IDENTITY':
|
|
693
|
+
this.toast({
|
|
694
|
+
icon: 'user',
|
|
695
|
+
segments: [
|
|
696
|
+
'In LearnCard, the learner would sign in. Using a practice profile for now.',
|
|
697
|
+
],
|
|
698
|
+
});
|
|
699
|
+
return Promise.resolve({
|
|
700
|
+
token: `mock-token-${Date.now()}`,
|
|
701
|
+
user: { ...this.identity },
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
case 'SEND_CREDENTIAL': {
|
|
705
|
+
const name = readAchievementName(payload);
|
|
706
|
+
const credentialId = `mock-credential-${Date.now()}-${(this.idSeq += 1)}`;
|
|
707
|
+
this.addCredential({
|
|
708
|
+
name,
|
|
709
|
+
credentialUri: credentialId,
|
|
710
|
+
credential: (payload as { credential?: unknown } | undefined)?.credential,
|
|
711
|
+
});
|
|
712
|
+
this.showClaimToast(name);
|
|
713
|
+
return Promise.resolve({ credentialId, stored: true });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
case 'REQUEST_CONSENT': {
|
|
717
|
+
const redirect = Boolean(
|
|
718
|
+
(payload as { redirect?: boolean } | undefined)?.redirect
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const scopes = (payload as { scopes?: unknown } | undefined)?.scopes;
|
|
723
|
+
|
|
724
|
+
if (scopes !== undefined) {
|
|
725
|
+
normalizeConsentRequest(scopes as ConsentRequest);
|
|
726
|
+
}
|
|
727
|
+
} catch (error) {
|
|
728
|
+
return Promise.reject(
|
|
729
|
+
new PartnerConnectError(
|
|
730
|
+
'CONSENT_SCOPES_INVALID',
|
|
731
|
+
error instanceof Error ? error.message : 'Invalid consent scopes'
|
|
732
|
+
)
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
this.showConsentToast(payload, redirect);
|
|
737
|
+
return Promise.resolve({ granted: true });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
case 'LAUNCH_FEATURE': {
|
|
741
|
+
const featurePath =
|
|
742
|
+
(payload as { featurePath?: string } | undefined)?.featurePath ?? '';
|
|
743
|
+
this.toast({
|
|
744
|
+
icon: 'open',
|
|
745
|
+
segments: featurePath
|
|
746
|
+
? [
|
|
747
|
+
'In LearnCard, this would open ',
|
|
748
|
+
{ b: readablePath(featurePath) },
|
|
749
|
+
'.',
|
|
750
|
+
]
|
|
751
|
+
: ['In LearnCard, this would open a feature screen.'],
|
|
752
|
+
});
|
|
753
|
+
return Promise.resolve({ launched: true, featurePath });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
case 'ASK_CREDENTIAL_SEARCH': {
|
|
757
|
+
const held = this.selfCredentials();
|
|
758
|
+
this.toast({
|
|
759
|
+
icon: 'search',
|
|
760
|
+
segments: held.length
|
|
761
|
+
? [
|
|
762
|
+
'The learner could share ',
|
|
763
|
+
{ b: plural(held.length, 'credential') },
|
|
764
|
+
'.',
|
|
765
|
+
]
|
|
766
|
+
: [
|
|
767
|
+
'In LearnCard, the learner would choose credentials to share. None yet in practice.',
|
|
768
|
+
],
|
|
769
|
+
});
|
|
770
|
+
return Promise.resolve({
|
|
771
|
+
verifiablePresentation: {
|
|
772
|
+
verifiableCredential: held.map(c => c.credential),
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
case 'ASK_CREDENTIAL_SPECIFIC': {
|
|
778
|
+
const credentialId = (payload as { credentialId?: string } | undefined)
|
|
779
|
+
?.credentialId;
|
|
780
|
+
const found = this.credentials.find(c => c.credentialUri === credentialId);
|
|
781
|
+
this.toast({
|
|
782
|
+
icon: 'search',
|
|
783
|
+
segments: found
|
|
784
|
+
? ['The learner would share ', { b: found.name }, '.']
|
|
785
|
+
: [
|
|
786
|
+
'In LearnCard, the learner would be asked to share this credential. It isn’t in practice data yet.',
|
|
787
|
+
],
|
|
788
|
+
});
|
|
789
|
+
return Promise.resolve({ credential: found?.credential });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
case 'INITIATE_TEMPLATE_ISSUE': {
|
|
793
|
+
const input = payload as
|
|
794
|
+
{ templateId?: string; draftRecipients?: string[] } | undefined;
|
|
795
|
+
const templateId = input?.templateId ?? '';
|
|
796
|
+
const recipients = Array.isArray(input?.draftRecipients)
|
|
797
|
+
? (input?.draftRecipients as string[])
|
|
798
|
+
: [];
|
|
799
|
+
for (const recipient of recipients) {
|
|
800
|
+
this.addCredential({
|
|
801
|
+
name: templateId || 'Boost',
|
|
802
|
+
boostUri: templateId,
|
|
803
|
+
recipient,
|
|
804
|
+
status: 'pending',
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
this.toast({
|
|
808
|
+
icon: 'send',
|
|
809
|
+
segments: recipients.length
|
|
810
|
+
? [
|
|
811
|
+
'In LearnCard, this would send to ',
|
|
812
|
+
{ b: plural(recipients.length, 'person') },
|
|
813
|
+
'.',
|
|
814
|
+
]
|
|
815
|
+
: ['In LearnCard, this would open the send screen.'],
|
|
816
|
+
});
|
|
817
|
+
return Promise.resolve({ issued: true });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
case 'REQUEST_LEARNER_CONTEXT': {
|
|
821
|
+
const opts = (payload ?? {}) as {
|
|
822
|
+
includeCredentials?: boolean;
|
|
823
|
+
format?: string;
|
|
824
|
+
};
|
|
825
|
+
const includeCredentials = opts.includeCredentials !== false;
|
|
826
|
+
const structured = opts.format === 'structured';
|
|
827
|
+
const held = includeCredentials ? this.selfCredentials() : [];
|
|
828
|
+
this.toast({
|
|
829
|
+
icon: 'sparkles',
|
|
830
|
+
segments: !includeCredentials
|
|
831
|
+
? [
|
|
832
|
+
'In LearnCard, the learner’s profile would load, without credentials.',
|
|
833
|
+
]
|
|
834
|
+
: held.length
|
|
835
|
+
? [
|
|
836
|
+
'Learner profile loaded with ',
|
|
837
|
+
{ b: plural(held.length, 'credential') },
|
|
838
|
+
'.',
|
|
839
|
+
]
|
|
840
|
+
: [
|
|
841
|
+
'In LearnCard, the learner’s profile would load here. Empty in practice.',
|
|
842
|
+
],
|
|
843
|
+
});
|
|
844
|
+
const prompt = !includeCredentials
|
|
845
|
+
? 'Mock learner context: credentials were not requested.'
|
|
846
|
+
: held.length
|
|
847
|
+
? `Mock learner context. Credentials held: ${held
|
|
848
|
+
.map(c => c.name)
|
|
849
|
+
.join(', ')}.`
|
|
850
|
+
: 'Mock learner context: this user has no credentials in standalone mode.';
|
|
851
|
+
return Promise.resolve({
|
|
852
|
+
status: 'ready',
|
|
853
|
+
prompt,
|
|
854
|
+
did: this.identity.did,
|
|
855
|
+
// Matches the real host: `raw` only ships for format: 'structured'.
|
|
856
|
+
...(structured
|
|
857
|
+
? { raw: { credentials: held.map(c => c.credential) } }
|
|
858
|
+
: {}),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
case 'GET_SYNC_STATUS':
|
|
863
|
+
this.toast({
|
|
864
|
+
icon: 'sync',
|
|
865
|
+
segments: [
|
|
866
|
+
'In LearnCard, this checks that the learner’s data is up to date. Ready in practice.',
|
|
867
|
+
],
|
|
868
|
+
});
|
|
869
|
+
return Promise.resolve({
|
|
870
|
+
status: 'ready',
|
|
871
|
+
progress: {
|
|
872
|
+
totalCredentials: 0,
|
|
873
|
+
completedCredentials: 0,
|
|
874
|
+
failedCredentials: 0,
|
|
875
|
+
retryCount: 0,
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
default:
|
|
880
|
+
this.toast({
|
|
881
|
+
icon: 'sparkles',
|
|
882
|
+
segments: ['In LearnCard, this would run ', { b: action }, '.'],
|
|
883
|
+
});
|
|
884
|
+
return Promise.resolve({});
|
|
885
|
+
}
|
|
886
|
+
})();
|
|
887
|
+
|
|
888
|
+
return response.then(result => {
|
|
889
|
+
this.captureHandledAction(action, payload);
|
|
890
|
+
return result;
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Tear down injected UI and clear in-memory state. */
|
|
895
|
+
public destroy(): void {
|
|
896
|
+
this.destroyed = true;
|
|
897
|
+
this.cancelIconCapture();
|
|
898
|
+
|
|
899
|
+
for (const entry of this.activeToasts.values()) {
|
|
900
|
+
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
|
901
|
+
}
|
|
902
|
+
this.activeToasts.clear();
|
|
903
|
+
|
|
904
|
+
for (const timer of this.exitTimers) clearTimeout(timer);
|
|
905
|
+
this.exitTimers.clear();
|
|
906
|
+
|
|
907
|
+
for (const node of this.domNodes) node.remove();
|
|
908
|
+
this.domNodes.clear();
|
|
909
|
+
|
|
910
|
+
if (this.stackEl) {
|
|
911
|
+
this.stackEl.remove();
|
|
912
|
+
this.stackEl = null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (this.styleEl) {
|
|
916
|
+
this.styleEl.remove();
|
|
917
|
+
this.styleEl = null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
this.memoryCounters.clear();
|
|
921
|
+
for (const key of Object.keys(this.memoryManifests)) delete this.memoryManifests[key];
|
|
922
|
+
for (const key of Object.keys(this.memoryPublishDismissedAt)) {
|
|
923
|
+
delete this.memoryPublishDismissedAt[key];
|
|
924
|
+
}
|
|
925
|
+
for (const key of Object.keys(this.memoryHudCollapsed)) delete this.memoryHudCollapsed[key];
|
|
926
|
+
this.credentials.length = 0;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
public getCapturedManifest(): CapturedAppManifest | undefined {
|
|
930
|
+
const manifest = this.loadManifest();
|
|
931
|
+
return manifest ? this.cloneManifest(manifest) : undefined;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
public getPublishOrigin(): string {
|
|
935
|
+
return this.options.publishOrigin;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
public resetPracticeMode(): void {
|
|
939
|
+
if (this.destroyed) return;
|
|
940
|
+
this.cancelIconCapture();
|
|
941
|
+
const manifests = this.loadManifestMap();
|
|
942
|
+
delete manifests[this.getAppFingerprint()];
|
|
943
|
+
this.replaceMemoryManifestMap(manifests);
|
|
944
|
+
if (this.options.persist) {
|
|
945
|
+
try {
|
|
946
|
+
localStorage.setItem(this.manifestStorageKey(), JSON.stringify(manifests));
|
|
947
|
+
for (const key of [
|
|
948
|
+
this.counterStorageKey(),
|
|
949
|
+
this.publishDismissedStorageKey(),
|
|
950
|
+
this.appKeyStorageKey(),
|
|
951
|
+
]) {
|
|
952
|
+
localStorage.removeItem(key);
|
|
953
|
+
}
|
|
954
|
+
} catch {
|
|
955
|
+
/* Storage may be unavailable. */
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
this.memoryCounters.clear();
|
|
959
|
+
this.memoryAppKey = undefined;
|
|
960
|
+
delete this.memoryPublishDismissedAt[this.getAppFingerprint()];
|
|
961
|
+
this.credentials.length = 0;
|
|
962
|
+
this.inlineTemplateVersions.clear();
|
|
963
|
+
this.aiTopic = null;
|
|
964
|
+
this.publishPromptShown = false;
|
|
965
|
+
this.confirmingReset = false;
|
|
966
|
+
for (const [key, entry] of this.activeToasts) {
|
|
967
|
+
if (entry.node.classList.contains('lc-mock-toast--publish')) this.dismissToast(key);
|
|
968
|
+
}
|
|
969
|
+
this.saveHudCollapsed(true);
|
|
970
|
+
this.updateManifestHud();
|
|
971
|
+
this.toast({ icon: 'sync', segments: ['Started fresh.'] });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private handleAppEvent(event: Record<string, unknown>): Promise<unknown> {
|
|
975
|
+
const type = typeof event?.type === 'string' ? event.type : '';
|
|
976
|
+
|
|
977
|
+
const response = (() => {
|
|
978
|
+
switch (type) {
|
|
979
|
+
case 'send-credential': {
|
|
980
|
+
if (
|
|
981
|
+
typeof event.alias === 'string' &&
|
|
982
|
+
event.alias &&
|
|
983
|
+
event.template &&
|
|
984
|
+
typeof event.template === 'object'
|
|
985
|
+
) {
|
|
986
|
+
const alias = event.alias;
|
|
987
|
+
const template = event.template as InlineCredentialTemplate;
|
|
988
|
+
const templateErrors = validateInlineTemplate(template);
|
|
989
|
+
|
|
990
|
+
if (templateErrors.length > 0) {
|
|
991
|
+
return Promise.reject(
|
|
992
|
+
new PartnerConnectError(
|
|
993
|
+
'TEMPLATE_INVALID',
|
|
994
|
+
templateErrors
|
|
995
|
+
.map(error => `${error.path}: ${error.message}`)
|
|
996
|
+
.join('\n')
|
|
997
|
+
)
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const compiled = compileInlineTemplate(template);
|
|
1002
|
+
const templateData =
|
|
1003
|
+
typeof event.templateData === 'object' && event.templateData !== null
|
|
1004
|
+
? (event.templateData as Record<string, unknown>)
|
|
1005
|
+
: undefined;
|
|
1006
|
+
const dataErrors = validateTemplateData(
|
|
1007
|
+
compiled.variableManifest,
|
|
1008
|
+
templateData
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
if (dataErrors.length > 0) {
|
|
1012
|
+
return Promise.reject(
|
|
1013
|
+
new PartnerConnectError(
|
|
1014
|
+
'TEMPLATE_DATA_INVALID',
|
|
1015
|
+
dataErrors
|
|
1016
|
+
.map(error => `${error.path}: ${error.message}`)
|
|
1017
|
+
.join('\n')
|
|
1018
|
+
)
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const canonicalTemplate = canonicalJsonString(template);
|
|
1023
|
+
const previousState = this.inlineTemplateVersions.get(alias);
|
|
1024
|
+
const templateVersion = previousState
|
|
1025
|
+
? previousState.canonicalTemplate === canonicalTemplate
|
|
1026
|
+
? previousState.version
|
|
1027
|
+
: previousState.version + 1
|
|
1028
|
+
: 1;
|
|
1029
|
+
|
|
1030
|
+
this.inlineTemplateVersions.set(alias, {
|
|
1031
|
+
canonicalTemplate,
|
|
1032
|
+
version: templateVersion,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const boostUri = `lc:mock:inline-boost:${alias}`;
|
|
1036
|
+
|
|
1037
|
+
if (event.preventDuplicateClaim) {
|
|
1038
|
+
const existing = this.selfCredentials().find(c =>
|
|
1039
|
+
this.matchesTemplate(c, { templateAlias: alias, boostUri })
|
|
1040
|
+
);
|
|
1041
|
+
if (existing) {
|
|
1042
|
+
this.toast({
|
|
1043
|
+
icon: 'award',
|
|
1044
|
+
segments: [
|
|
1045
|
+
'The learner already has ',
|
|
1046
|
+
{ b: existing.name },
|
|
1047
|
+
'.',
|
|
1048
|
+
],
|
|
1049
|
+
});
|
|
1050
|
+
return Promise.resolve({
|
|
1051
|
+
credentialUri: existing.credentialUri,
|
|
1052
|
+
boostUri: existing.boostUri ?? boostUri,
|
|
1053
|
+
alreadyClaimed: true,
|
|
1054
|
+
hasCredential: true,
|
|
1055
|
+
status: existing.status,
|
|
1056
|
+
receivedDate: existing.receivedDate,
|
|
1057
|
+
templateVersion,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const renderedCredential = renderCompiledTemplate(
|
|
1063
|
+
compiled.credentialTemplateJson,
|
|
1064
|
+
{
|
|
1065
|
+
...(templateData ?? {}),
|
|
1066
|
+
issue_date: new Date().toISOString(),
|
|
1067
|
+
issuer_did: 'did:web:mock.learncard.app:app',
|
|
1068
|
+
recipient_did: this.identity.did,
|
|
1069
|
+
}
|
|
1070
|
+
);
|
|
1071
|
+
renderedCredential._mock = true;
|
|
1072
|
+
|
|
1073
|
+
const renderedName = readRenderedCredentialName(renderedCredential);
|
|
1074
|
+
const record = this.addCredential({
|
|
1075
|
+
name: renderedName,
|
|
1076
|
+
templateAlias: alias,
|
|
1077
|
+
boostUri,
|
|
1078
|
+
status: 'claimed',
|
|
1079
|
+
credential: renderedCredential,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
this.showClaimToast(renderedName, templateVersion);
|
|
1083
|
+
|
|
1084
|
+
return Promise.resolve({
|
|
1085
|
+
credentialUri: record.credentialUri,
|
|
1086
|
+
boostUri: record.boostUri ?? boostUri,
|
|
1087
|
+
alreadyClaimed: false,
|
|
1088
|
+
hasCredential: true,
|
|
1089
|
+
status: 'claimed',
|
|
1090
|
+
receivedDate: record.receivedDate,
|
|
1091
|
+
templateVersion,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const name = readAchievementName(event);
|
|
1096
|
+
const templateAlias =
|
|
1097
|
+
typeof event.templateAlias === 'string' ? event.templateAlias : undefined;
|
|
1098
|
+
const boostUri = `lc:mock:boost:${String(event.templateAlias ?? 'template')}`;
|
|
1099
|
+
|
|
1100
|
+
if (event.preventDuplicateClaim) {
|
|
1101
|
+
const existing = this.selfCredentials().find(c =>
|
|
1102
|
+
this.matchesTemplate(c, { templateAlias, boostUri })
|
|
1103
|
+
);
|
|
1104
|
+
if (existing) {
|
|
1105
|
+
this.toast({
|
|
1106
|
+
icon: 'award',
|
|
1107
|
+
segments: ['The learner already has ', { b: existing.name }, '.'],
|
|
1108
|
+
});
|
|
1109
|
+
return Promise.resolve({
|
|
1110
|
+
credentialUri: existing.credentialUri,
|
|
1111
|
+
boostUri: existing.boostUri ?? boostUri,
|
|
1112
|
+
alreadyClaimed: true,
|
|
1113
|
+
hasCredential: true,
|
|
1114
|
+
status: existing.status,
|
|
1115
|
+
receivedDate: existing.receivedDate,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const record = this.addCredential({
|
|
1121
|
+
name,
|
|
1122
|
+
templateAlias,
|
|
1123
|
+
boostUri,
|
|
1124
|
+
status: 'claimed',
|
|
1125
|
+
});
|
|
1126
|
+
this.showClaimToast(name);
|
|
1127
|
+
return Promise.resolve({
|
|
1128
|
+
credentialUri: record.credentialUri,
|
|
1129
|
+
boostUri: record.boostUri ?? boostUri,
|
|
1130
|
+
alreadyClaimed: false,
|
|
1131
|
+
hasCredential: true,
|
|
1132
|
+
status: 'claimed',
|
|
1133
|
+
receivedDate: record.receivedDate,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
case 'check-credential': {
|
|
1138
|
+
const held = this.selfCredentials().find(c => this.matchesTemplate(c, event));
|
|
1139
|
+
this.toast({
|
|
1140
|
+
icon: 'search',
|
|
1141
|
+
segments: held
|
|
1142
|
+
? ['The learner already has ', { b: held.name }, '.']
|
|
1143
|
+
: ['The learner doesn’t have this credential yet.'],
|
|
1144
|
+
});
|
|
1145
|
+
return Promise.resolve(
|
|
1146
|
+
held
|
|
1147
|
+
? {
|
|
1148
|
+
hasCredential: true,
|
|
1149
|
+
credentialUri: held.credentialUri,
|
|
1150
|
+
receivedDate: held.receivedDate,
|
|
1151
|
+
status: held.status,
|
|
1152
|
+
}
|
|
1153
|
+
: { hasCredential: false }
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
case 'check-issuance-status': {
|
|
1158
|
+
const recipient = typeof event.recipient === 'string' ? event.recipient : '';
|
|
1159
|
+
const match = this.credentials.find(
|
|
1160
|
+
c => this.matchesTemplate(c, event) && c.recipient === recipient
|
|
1161
|
+
);
|
|
1162
|
+
this.toast({
|
|
1163
|
+
icon: 'send',
|
|
1164
|
+
segments: match
|
|
1165
|
+
? ['Sent to ', { b: recipient }, ' — ', { b: match.status }, '.']
|
|
1166
|
+
: ['Not sent to this person yet.'],
|
|
1167
|
+
});
|
|
1168
|
+
return Promise.resolve(
|
|
1169
|
+
match
|
|
1170
|
+
? {
|
|
1171
|
+
sent: true,
|
|
1172
|
+
credentialUri: match.credentialUri,
|
|
1173
|
+
sentDate: match.sentDate,
|
|
1174
|
+
claimedDate: match.claimedDate,
|
|
1175
|
+
status: match.status,
|
|
1176
|
+
}
|
|
1177
|
+
: { sent: false }
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
case 'get-template-recipients': {
|
|
1182
|
+
const matched = this.credentials.filter(c => this.matchesTemplate(c, event));
|
|
1183
|
+
const limit =
|
|
1184
|
+
typeof event.limit === 'number' && event.limit > 0
|
|
1185
|
+
? event.limit
|
|
1186
|
+
: undefined;
|
|
1187
|
+
// Cursor pagination: the cursor is the offset of the next record,
|
|
1188
|
+
// issued by the previous page so callers can walk the full list.
|
|
1189
|
+
const parsedCursor =
|
|
1190
|
+
typeof event.cursor === 'string' ? Number.parseInt(event.cursor, 10) : 0;
|
|
1191
|
+
const offset =
|
|
1192
|
+
Number.isFinite(parsedCursor) && parsedCursor > 0 ? parsedCursor : 0;
|
|
1193
|
+
const page = limit
|
|
1194
|
+
? matched.slice(offset, offset + limit)
|
|
1195
|
+
: matched.slice(offset);
|
|
1196
|
+
const nextOffset = offset + page.length;
|
|
1197
|
+
const hasMore = nextOffset < matched.length;
|
|
1198
|
+
this.toast({
|
|
1199
|
+
icon: 'users',
|
|
1200
|
+
segments: [
|
|
1201
|
+
'In LearnCard, this lists who received it. ',
|
|
1202
|
+
{ b: String(matched.length) },
|
|
1203
|
+
' so far in practice.',
|
|
1204
|
+
],
|
|
1205
|
+
});
|
|
1206
|
+
return Promise.resolve({
|
|
1207
|
+
records: page.map(c => this.toRecipientRecord(c)),
|
|
1208
|
+
hasMore,
|
|
1209
|
+
...(hasMore ? { cursor: String(nextOffset) } : {}),
|
|
1210
|
+
total: matched.length,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
case 'send-notification': {
|
|
1215
|
+
const title = typeof event.title === 'string' ? event.title : '';
|
|
1216
|
+
const body = typeof event.body === 'string' ? event.body : '';
|
|
1217
|
+
const text = [title, body].filter(Boolean).join(' — ');
|
|
1218
|
+
this.toast({
|
|
1219
|
+
icon: 'bell',
|
|
1220
|
+
segments: text
|
|
1221
|
+
? ['The learner would get a notification: ', { b: text }]
|
|
1222
|
+
: ['In LearnCard, the learner would get a notification.'],
|
|
1223
|
+
});
|
|
1224
|
+
return Promise.resolve({ sent: true });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
case 'increment-counter': {
|
|
1228
|
+
const key = String(event.key ?? '');
|
|
1229
|
+
const amount = typeof event.amount === 'number' ? event.amount : 0;
|
|
1230
|
+
const previous = this.readCounter(key)?.value ?? 0;
|
|
1231
|
+
const next = previous + amount;
|
|
1232
|
+
this.writeCounter(key, next);
|
|
1233
|
+
this.toast({
|
|
1234
|
+
icon: 'hash',
|
|
1235
|
+
segments: ['Counter ', { b: key }, ' → ', { b: String(next) }, '.'],
|
|
1236
|
+
});
|
|
1237
|
+
return Promise.resolve({ key, previousValue: previous, newValue: next });
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
case 'get-counter': {
|
|
1241
|
+
const key = String(event.key ?? '');
|
|
1242
|
+
const stored = this.readCounter(key);
|
|
1243
|
+
const value = stored?.value ?? 0;
|
|
1244
|
+
this.toast({
|
|
1245
|
+
icon: 'hash',
|
|
1246
|
+
segments: ['Counter ', { b: key }, ' is ', { b: String(value) }, '.'],
|
|
1247
|
+
});
|
|
1248
|
+
return Promise.resolve({
|
|
1249
|
+
key,
|
|
1250
|
+
value,
|
|
1251
|
+
updatedAt: stored?.updatedAt ?? null,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
case 'get-counters': {
|
|
1256
|
+
const requested = Array.isArray(event.keys)
|
|
1257
|
+
? (event.keys as unknown[]).map(String)
|
|
1258
|
+
: this.allCounterKeys();
|
|
1259
|
+
const counters = requested.map(key => {
|
|
1260
|
+
const stored = this.readCounter(key);
|
|
1261
|
+
return {
|
|
1262
|
+
key,
|
|
1263
|
+
value: stored?.value ?? 0,
|
|
1264
|
+
updatedAt: stored?.updatedAt ?? null,
|
|
1265
|
+
};
|
|
1266
|
+
});
|
|
1267
|
+
this.toast({
|
|
1268
|
+
icon: 'hash',
|
|
1269
|
+
segments: ['Read ', { b: plural(counters.length, 'counter') }, '.'],
|
|
1270
|
+
});
|
|
1271
|
+
return Promise.resolve({ counters });
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
case 'send-ai-session-credential': {
|
|
1275
|
+
const sessionTitle =
|
|
1276
|
+
typeof event.sessionTitle === 'string' && event.sessionTitle
|
|
1277
|
+
? event.sessionTitle
|
|
1278
|
+
: 'AI session';
|
|
1279
|
+
const sessionBoostUri = this.nextUri('lc:mock:session-boost');
|
|
1280
|
+
const record = this.addCredential({
|
|
1281
|
+
name: sessionTitle,
|
|
1282
|
+
boostUri: sessionBoostUri,
|
|
1283
|
+
status: 'claimed',
|
|
1284
|
+
});
|
|
1285
|
+
this.toast({
|
|
1286
|
+
icon: 'award',
|
|
1287
|
+
tone: 'positive',
|
|
1288
|
+
segments: [
|
|
1289
|
+
'In LearnCard, the learner would keep a record of ',
|
|
1290
|
+
{ b: sessionTitle },
|
|
1291
|
+
'.',
|
|
1292
|
+
],
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
// Mirrors the real host's topic hierarchy: the first session
|
|
1296
|
+
// creates the app's AI Topic (returning its credential URI);
|
|
1297
|
+
// later sessions reuse it and report isNewTopic: false.
|
|
1298
|
+
const isNewTopic = this.aiTopic === null;
|
|
1299
|
+
if (!this.aiTopic) {
|
|
1300
|
+
this.aiTopic = {
|
|
1301
|
+
topicUri: this.nextUri('lc:mock:topic'),
|
|
1302
|
+
topicCredentialUri: this.nextUri('lc:mock:topic-credential'),
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return Promise.resolve({
|
|
1307
|
+
topicUri: this.aiTopic.topicUri,
|
|
1308
|
+
...(isNewTopic
|
|
1309
|
+
? { topicCredentialUri: this.aiTopic.topicCredentialUri }
|
|
1310
|
+
: {}),
|
|
1311
|
+
sessionCredentialUri: record.credentialUri,
|
|
1312
|
+
sessionBoostUri,
|
|
1313
|
+
isNewTopic,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
default:
|
|
1318
|
+
this.toast({
|
|
1319
|
+
icon: 'sparkles',
|
|
1320
|
+
segments: ['In LearnCard, this would run ', { b: type }, '.'],
|
|
1321
|
+
});
|
|
1322
|
+
return Promise.resolve({});
|
|
1323
|
+
}
|
|
1324
|
+
})();
|
|
1325
|
+
|
|
1326
|
+
return response.then(result => {
|
|
1327
|
+
this.captureHandledAppEvent(type, event);
|
|
1328
|
+
return result;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private captureHandledAction(action: string, payload?: unknown): void {
|
|
1333
|
+
try {
|
|
1334
|
+
this.updateManifest(draft => {
|
|
1335
|
+
switch (action) {
|
|
1336
|
+
case 'REQUEST_IDENTITY':
|
|
1337
|
+
this.addPermission(draft, 'request_identity');
|
|
1338
|
+
break;
|
|
1339
|
+
case 'SEND_CREDENTIAL':
|
|
1340
|
+
this.addPermission(draft, 'send_credential');
|
|
1341
|
+
break;
|
|
1342
|
+
case 'REQUEST_CONSENT': {
|
|
1343
|
+
this.addPermission(draft, 'request_consent');
|
|
1344
|
+
const scopes = (payload as { scopes?: unknown } | undefined)?.scopes;
|
|
1345
|
+
if (scopes && typeof scopes === 'object' && !Array.isArray(scopes)) {
|
|
1346
|
+
this.captureConsentRecord(draft, scopes as ConsentRequest);
|
|
1347
|
+
}
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
case 'LAUNCH_FEATURE': {
|
|
1351
|
+
this.addPermission(draft, 'launch_feature');
|
|
1352
|
+
const featurePath = (payload as { featurePath?: unknown } | undefined)
|
|
1353
|
+
?.featurePath;
|
|
1354
|
+
if (typeof featurePath === 'string' && featurePath) {
|
|
1355
|
+
this.addUnique(draft.featuresLaunched, featurePath);
|
|
1356
|
+
}
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case 'ASK_CREDENTIAL_SEARCH':
|
|
1360
|
+
this.addPermission(draft, 'credential_search');
|
|
1361
|
+
break;
|
|
1362
|
+
case 'ASK_CREDENTIAL_SPECIFIC':
|
|
1363
|
+
this.addPermission(draft, 'credential_by_id');
|
|
1364
|
+
break;
|
|
1365
|
+
case 'INITIATE_TEMPLATE_ISSUE':
|
|
1366
|
+
this.addPermission(draft, 'template_issuance');
|
|
1367
|
+
break;
|
|
1368
|
+
case 'REQUEST_LEARNER_CONTEXT':
|
|
1369
|
+
draft.usedLearnerContext = true;
|
|
1370
|
+
break;
|
|
1371
|
+
default:
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
} catch {
|
|
1376
|
+
// Capture must never affect mocked app behavior.
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
private captureHandledAppEvent(type: string, event: Record<string, unknown>): void {
|
|
1381
|
+
try {
|
|
1382
|
+
this.updateManifest(draft => {
|
|
1383
|
+
switch (type) {
|
|
1384
|
+
case 'send-credential': {
|
|
1385
|
+
this.addPermission(draft, 'send_credential');
|
|
1386
|
+
if (
|
|
1387
|
+
typeof event.alias === 'string' &&
|
|
1388
|
+
event.alias &&
|
|
1389
|
+
event.template &&
|
|
1390
|
+
typeof event.template === 'object'
|
|
1391
|
+
) {
|
|
1392
|
+
const version =
|
|
1393
|
+
this.inlineTemplateVersions.get(event.alias)?.version ?? 1;
|
|
1394
|
+
this.captureTemplateRecord(
|
|
1395
|
+
draft,
|
|
1396
|
+
event.alias,
|
|
1397
|
+
event.template as InlineCredentialTemplate,
|
|
1398
|
+
version
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
case 'send-notification':
|
|
1404
|
+
draft.usedNotifications = true;
|
|
1405
|
+
break;
|
|
1406
|
+
case 'increment-counter':
|
|
1407
|
+
case 'get-counter': {
|
|
1408
|
+
const key = event.key;
|
|
1409
|
+
if (typeof key === 'string' && key) this.addUnique(draft.counterKeys, key);
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
case 'get-counters': {
|
|
1413
|
+
if (Array.isArray(event.keys)) {
|
|
1414
|
+
for (const key of event.keys) {
|
|
1415
|
+
if (typeof key === 'string' && key) {
|
|
1416
|
+
this.addUnique(draft.counterKeys, key);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
default:
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
} catch {
|
|
1427
|
+
// Capture must never affect mocked app behavior.
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
private updateManifest(mutator: (manifest: CapturedAppManifest) => void): void {
|
|
1432
|
+
const manifest = this.loadManifest() ?? this.createManifestSkeleton();
|
|
1433
|
+
const next = this.cloneManifest(manifest);
|
|
1434
|
+
|
|
1435
|
+
mutator(next);
|
|
1436
|
+
|
|
1437
|
+
const now = new Date().toISOString();
|
|
1438
|
+
next.firstCapturedAt ||= now;
|
|
1439
|
+
next.lastUpdatedAt = now;
|
|
1440
|
+
|
|
1441
|
+
this.saveManifest(next);
|
|
1442
|
+
this.updateManifestHud(next);
|
|
1443
|
+
this.refreshPublishPromptLink(next);
|
|
1444
|
+
this.maybeShowPublishPrompt(next);
|
|
1445
|
+
this.captureIcon();
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
private createManifestSkeleton(): CapturedAppManifest {
|
|
1449
|
+
const now = new Date().toISOString();
|
|
1450
|
+
const suggestedName = this.readSuggestedName();
|
|
1451
|
+
const suggestedIconUrl = this.readSuggestedIconUrl();
|
|
1452
|
+
|
|
1453
|
+
return {
|
|
1454
|
+
manifestVersion: 1,
|
|
1455
|
+
appKey: this.getAppKey(),
|
|
1456
|
+
appUrl: this.readAppOrigin(),
|
|
1457
|
+
...(suggestedName ? { suggestedName } : {}),
|
|
1458
|
+
...(suggestedIconUrl ? { suggestedIconUrl } : {}),
|
|
1459
|
+
permissions: [],
|
|
1460
|
+
templates: [],
|
|
1461
|
+
consentRequests: [],
|
|
1462
|
+
featuresLaunched: [],
|
|
1463
|
+
counterKeys: [],
|
|
1464
|
+
usedLearnerContext: false,
|
|
1465
|
+
usedNotifications: false,
|
|
1466
|
+
firstCapturedAt: now,
|
|
1467
|
+
lastUpdatedAt: now,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private readAppOrigin(): string {
|
|
1472
|
+
if (typeof window === 'undefined' || !window.location?.origin) return '';
|
|
1473
|
+
return `${window.location.origin}${window.location.pathname || ''}`;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private readSuggestedName(): string | undefined {
|
|
1477
|
+
const title = this.readInitialDocumentTitle();
|
|
1478
|
+
if (!title) return undefined;
|
|
1479
|
+
|
|
1480
|
+
const trimmedTitle = title.trim();
|
|
1481
|
+
if (!trimmedTitle) return undefined;
|
|
1482
|
+
|
|
1483
|
+
return trimmedTitle.slice(0, 100);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private readInitialDocumentTitle(): string | undefined {
|
|
1487
|
+
if (this.initialDocumentTitle !== undefined) {
|
|
1488
|
+
return this.initialDocumentTitle ?? undefined;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (typeof document === 'undefined') {
|
|
1492
|
+
this.initialDocumentTitle = null;
|
|
1493
|
+
return undefined;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
this.initialDocumentTitle = document.title;
|
|
1497
|
+
return this.initialDocumentTitle || undefined;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
private getAppFingerprint(): string {
|
|
1501
|
+
if (this.appFingerprint) return this.appFingerprint;
|
|
1502
|
+
|
|
1503
|
+
this.appFingerprint = slugifyAppFingerprint(
|
|
1504
|
+
this.options.appId ?? this.readInitialDocumentTitle() ?? undefined
|
|
1505
|
+
);
|
|
1506
|
+
|
|
1507
|
+
return this.appFingerprint;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
private readLegacyManifestFingerprint(manifest: CapturedAppManifest): string {
|
|
1511
|
+
return slugifyAppFingerprint(manifest.suggestedName);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private readSuggestedIconUrl(): string | undefined {
|
|
1515
|
+
return this.iconCandidates().find(url => /^https?:/.test(url));
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
private appKeyStorageKey(): string {
|
|
1519
|
+
return `${this.options.namespace}:app-key:${this.getAppFingerprint()}`;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
private getAppKey(): string {
|
|
1523
|
+
if (this.options.appId !== undefined) return this.options.appId;
|
|
1524
|
+
if (this.options.persist) {
|
|
1525
|
+
try {
|
|
1526
|
+
this.memoryAppKey =
|
|
1527
|
+
localStorage.getItem(this.appKeyStorageKey()) || this.memoryAppKey;
|
|
1528
|
+
} catch {
|
|
1529
|
+
/* Use the session fallback. */
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
this.memoryAppKey ??=
|
|
1533
|
+
globalThis.crypto?.randomUUID?.() ??
|
|
1534
|
+
`app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1535
|
+
if (this.options.persist) {
|
|
1536
|
+
try {
|
|
1537
|
+
localStorage.setItem(this.appKeyStorageKey(), this.memoryAppKey);
|
|
1538
|
+
} catch {
|
|
1539
|
+
/* Use the session fallback. */
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return this.memoryAppKey;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
private iconFallback(): string | undefined {
|
|
1546
|
+
return typeof window !== 'undefined'
|
|
1547
|
+
? this.resolveIcon('/favicon.ico', window.location.origin)
|
|
1548
|
+
: undefined;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
private resolveIcon(href: string, base: string): string | undefined {
|
|
1552
|
+
try {
|
|
1553
|
+
const url = new URL(href, base);
|
|
1554
|
+
return ['http:', 'https:', 'data:'].includes(url.protocol) ? url.href : undefined;
|
|
1555
|
+
} catch {
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
private iconSize(sizes: string, type: string): number {
|
|
1561
|
+
if (sizes.toLowerCase().split(/\s+/).includes('any') || type === 'image/svg+xml')
|
|
1562
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1563
|
+
return Math.max(
|
|
1564
|
+
0,
|
|
1565
|
+
...sizes.split(/\s+/).map(size => {
|
|
1566
|
+
const match = /^(\d+)x(\d+)$/i.exec(size);
|
|
1567
|
+
return match ? Number(match[1]) * Number(match[2]) : 0;
|
|
1568
|
+
})
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
private iconCandidates(): string[] {
|
|
1573
|
+
if (typeof document === 'undefined') return [];
|
|
1574
|
+
return Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel]'))
|
|
1575
|
+
.map(link => {
|
|
1576
|
+
const rels = link.rel.toLowerCase().split(/\s+/);
|
|
1577
|
+
const priority = rels.includes('mask-icon')
|
|
1578
|
+
? 0
|
|
1579
|
+
: rels.some(
|
|
1580
|
+
rel =>
|
|
1581
|
+
rel === 'apple-touch-icon' || rel === 'apple-touch-icon-precomposed'
|
|
1582
|
+
)
|
|
1583
|
+
? 2
|
|
1584
|
+
: rels.includes('icon')
|
|
1585
|
+
? 1
|
|
1586
|
+
: 0;
|
|
1587
|
+
return {
|
|
1588
|
+
priority,
|
|
1589
|
+
size: this.iconSize(link.getAttribute('sizes') ?? '', link.type),
|
|
1590
|
+
url: this.resolveIcon(link.href, document.baseURI),
|
|
1591
|
+
};
|
|
1592
|
+
})
|
|
1593
|
+
.filter(candidate => candidate.priority > 0 && candidate.url)
|
|
1594
|
+
.sort((a, b) => b.priority - a.priority || b.size - a.size)
|
|
1595
|
+
.flatMap(candidate => (candidate.url ? [candidate.url] : []));
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
private cancelIconCapture(): void {
|
|
1599
|
+
this.iconGeneration++;
|
|
1600
|
+
for (const cleanup of this.iconCleanups) cleanup();
|
|
1601
|
+
this.iconCleanups.clear();
|
|
1602
|
+
this.iconSource = undefined;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
private captureIcon(): void {
|
|
1606
|
+
if (this.destroyed || typeof document === 'undefined') return;
|
|
1607
|
+
const candidates = this.iconCandidates();
|
|
1608
|
+
const manifestHref = document.querySelector<HTMLLinkElement>('link[rel~="manifest"]')?.href;
|
|
1609
|
+
const signature = JSON.stringify([candidates, manifestHref]);
|
|
1610
|
+
if (signature === this.iconSource) return;
|
|
1611
|
+
this.cancelIconCapture();
|
|
1612
|
+
this.iconSource = signature;
|
|
1613
|
+
const generation = this.iconGeneration;
|
|
1614
|
+
void (async () => {
|
|
1615
|
+
let source: string | undefined = candidates[0];
|
|
1616
|
+
let url = candidates.find(candidate => /^https?:/.test(candidate));
|
|
1617
|
+
if (
|
|
1618
|
+
(!source || !url) &&
|
|
1619
|
+
manifestHref &&
|
|
1620
|
+
new URL(manifestHref).origin === window.location.origin
|
|
1621
|
+
) {
|
|
1622
|
+
const controller = new AbortController();
|
|
1623
|
+
let rejectPending: (reason: Error) => void = () => undefined;
|
|
1624
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1625
|
+
rejectPending = reject;
|
|
1626
|
+
});
|
|
1627
|
+
const cleanup = (): void => {
|
|
1628
|
+
clearTimeout(timer);
|
|
1629
|
+
controller.abort();
|
|
1630
|
+
rejectPending(new Error('Icon capture cancelled'));
|
|
1631
|
+
};
|
|
1632
|
+
const timer = setTimeout(cleanup, 4000);
|
|
1633
|
+
this.iconCleanups.add(cleanup);
|
|
1634
|
+
try {
|
|
1635
|
+
const data: unknown = await Promise.race([
|
|
1636
|
+
fetch(manifestHref, { signal: controller.signal, redirect: 'error' }).then(
|
|
1637
|
+
response => {
|
|
1638
|
+
if (!response.ok) return undefined;
|
|
1639
|
+
return response.json() as Promise<unknown>;
|
|
1640
|
+
}
|
|
1641
|
+
),
|
|
1642
|
+
timeout,
|
|
1643
|
+
]);
|
|
1644
|
+
{
|
|
1645
|
+
const icons =
|
|
1646
|
+
data && typeof data === 'object' && 'icons' in data
|
|
1647
|
+
? data.icons
|
|
1648
|
+
: undefined;
|
|
1649
|
+
if (Array.isArray(icons)) {
|
|
1650
|
+
const ranked = icons
|
|
1651
|
+
.filter(
|
|
1652
|
+
(
|
|
1653
|
+
icon
|
|
1654
|
+
): icon is { src: string; sizes?: string; type?: string } =>
|
|
1655
|
+
!!icon &&
|
|
1656
|
+
typeof icon === 'object' &&
|
|
1657
|
+
typeof icon.src === 'string'
|
|
1658
|
+
)
|
|
1659
|
+
.sort(
|
|
1660
|
+
(a, b) =>
|
|
1661
|
+
this.iconSize(
|
|
1662
|
+
typeof b.sizes === 'string' ? b.sizes : '',
|
|
1663
|
+
b.type ?? ''
|
|
1664
|
+
) -
|
|
1665
|
+
this.iconSize(
|
|
1666
|
+
typeof a.sizes === 'string' ? a.sizes : '',
|
|
1667
|
+
a.type ?? ''
|
|
1668
|
+
)
|
|
1669
|
+
)
|
|
1670
|
+
.flatMap(icon => {
|
|
1671
|
+
const href = this.resolveIcon(icon.src, manifestHref);
|
|
1672
|
+
return href ? [href] : [];
|
|
1673
|
+
});
|
|
1674
|
+
source ??= ranked[0];
|
|
1675
|
+
url ??= ranked.find(href => /^https?:/.test(href));
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
} catch {
|
|
1679
|
+
/* Icon discovery is best effort. */
|
|
1680
|
+
} finally {
|
|
1681
|
+
cleanup();
|
|
1682
|
+
this.iconCleanups.delete(cleanup);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
if (this.destroyed || generation !== this.iconGeneration) return;
|
|
1686
|
+
const fallback = url ? undefined : this.iconFallback();
|
|
1687
|
+
url ??= fallback;
|
|
1688
|
+
source ??= url;
|
|
1689
|
+
const result = source ? await this.rasterizeIcon(source) : undefined;
|
|
1690
|
+
if (this.destroyed || generation !== this.iconGeneration) return;
|
|
1691
|
+
const manifest = this.loadManifest();
|
|
1692
|
+
if (!manifest) return;
|
|
1693
|
+
// A guessed /favicon.ico, or a same-origin icon that fails to load, is broken.
|
|
1694
|
+
// Cross-origin failures stay: a missing CORS header looks like an error too.
|
|
1695
|
+
const isSameOrigin = (href: string): boolean =>
|
|
1696
|
+
href.startsWith(window.location.origin + '/');
|
|
1697
|
+
const broken =
|
|
1698
|
+
result?.status !== 'loaded' &&
|
|
1699
|
+
(url === fallback || (!!url && result?.status === 'failed' && isSameOrigin(url)));
|
|
1700
|
+
manifest.suggestedIconUrl = broken ? undefined : url;
|
|
1701
|
+
manifest.suggestedIconDataUrl = result?.dataUrl;
|
|
1702
|
+
this.saveManifest(manifest);
|
|
1703
|
+
this.updateManifestHud(manifest);
|
|
1704
|
+
this.refreshPublishPromptLink(manifest);
|
|
1705
|
+
})().catch(() => undefined);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
private rasterizeIcon(
|
|
1709
|
+
source: string
|
|
1710
|
+
): Promise<{ status: 'loaded' | 'failed' | 'timeout'; dataUrl?: string }> {
|
|
1711
|
+
return new Promise(resolve => {
|
|
1712
|
+
const image = new Image();
|
|
1713
|
+
const finish = (status: 'loaded' | 'failed' | 'timeout', dataUrl?: string): void => {
|
|
1714
|
+
clearTimeout(timer);
|
|
1715
|
+
image.onload = null;
|
|
1716
|
+
image.onerror = null;
|
|
1717
|
+
this.iconCleanups.delete(cancel);
|
|
1718
|
+
resolve({ status, dataUrl });
|
|
1719
|
+
};
|
|
1720
|
+
const cancel = (): void => {
|
|
1721
|
+
finish('timeout');
|
|
1722
|
+
image.removeAttribute('src');
|
|
1723
|
+
};
|
|
1724
|
+
const timer = setTimeout(cancel, 4000);
|
|
1725
|
+
this.iconCleanups.add(cancel);
|
|
1726
|
+
image.onerror = () => finish('failed');
|
|
1727
|
+
image.onload = () => {
|
|
1728
|
+
try {
|
|
1729
|
+
if (!image.naturalWidth || !image.naturalHeight) return finish('failed');
|
|
1730
|
+
const canvas = document.createElement('canvas');
|
|
1731
|
+
for (const size of [128, 96, 64]) {
|
|
1732
|
+
canvas.width = canvas.height = size;
|
|
1733
|
+
const context = canvas.getContext('2d');
|
|
1734
|
+
if (!context) break;
|
|
1735
|
+
const ratio = size / Math.max(image.naturalWidth, image.naturalHeight);
|
|
1736
|
+
const width = image.naturalWidth * ratio;
|
|
1737
|
+
const height = image.naturalHeight * ratio;
|
|
1738
|
+
context.drawImage(
|
|
1739
|
+
image,
|
|
1740
|
+
(size - width) / 2,
|
|
1741
|
+
(size - height) / 2,
|
|
1742
|
+
width,
|
|
1743
|
+
height
|
|
1744
|
+
);
|
|
1745
|
+
const data = canvas.toDataURL('image/webp', 0.85);
|
|
1746
|
+
if (data.startsWith('data:image/') && data.length <= 3000)
|
|
1747
|
+
return finish('loaded', data);
|
|
1748
|
+
}
|
|
1749
|
+
} catch {
|
|
1750
|
+
/* Tainted or unsupported canvas. */
|
|
1751
|
+
}
|
|
1752
|
+
finish('loaded');
|
|
1753
|
+
};
|
|
1754
|
+
if (/^https?:/.test(source) && new URL(source).origin !== window.location.origin)
|
|
1755
|
+
image.crossOrigin = 'anonymous';
|
|
1756
|
+
image.src = source;
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
private manifestStorageKey(): string {
|
|
1761
|
+
return `${this.options.namespace}:manifests`;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private legacyManifestStorageKey(): string {
|
|
1765
|
+
return `${this.options.namespace}:manifest`;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
private publishDismissedStorageKey(): string {
|
|
1769
|
+
return `${this.options.namespace}:publish-dismissed-at:${this.getAppFingerprint()}`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
private hudCollapsedStorageKey(): string {
|
|
1773
|
+
return `${this.options.namespace}:manifest-hud-collapsed:${this.getAppFingerprint()}`;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
private loadManifestMap(): StoredManifestMap {
|
|
1777
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1778
|
+
try {
|
|
1779
|
+
const rawMap = localStorage.getItem(this.manifestStorageKey());
|
|
1780
|
+
const rawLegacy = localStorage.getItem(this.legacyManifestStorageKey());
|
|
1781
|
+
|
|
1782
|
+
if (!rawMap && !rawLegacy && Object.keys(this.memoryManifests).length > 0) {
|
|
1783
|
+
return this.cloneManifestMap(this.memoryManifests);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const manifests = rawMap ? this.parseManifestMap(rawMap) : {};
|
|
1787
|
+
|
|
1788
|
+
if (rawLegacy) {
|
|
1789
|
+
try {
|
|
1790
|
+
const legacyManifest = JSON.parse(rawLegacy) as CapturedAppManifest;
|
|
1791
|
+
manifests[this.readLegacyManifestFingerprint(legacyManifest)] =
|
|
1792
|
+
this.cloneManifest(legacyManifest);
|
|
1793
|
+
localStorage.setItem(this.manifestStorageKey(), JSON.stringify(manifests));
|
|
1794
|
+
localStorage.removeItem(this.legacyManifestStorageKey());
|
|
1795
|
+
} catch {
|
|
1796
|
+
// Ignore corrupt legacy data and leave it in place.
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
this.replaceMemoryManifestMap(manifests);
|
|
1801
|
+
return this.cloneManifestMap(manifests);
|
|
1802
|
+
} catch {
|
|
1803
|
+
// Ignore and fall back to memory.
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
return this.cloneManifestMap(this.memoryManifests);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
private parseManifestMap(raw: string): StoredManifestMap {
|
|
1811
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
1812
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
1813
|
+
|
|
1814
|
+
const manifests: StoredManifestMap = {};
|
|
1815
|
+
for (const [fingerprint, manifest] of Object.entries(parsed)) {
|
|
1816
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
|
|
1817
|
+
manifests[fingerprint] = this.cloneManifest(manifest as CapturedAppManifest);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
return manifests;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
private cloneManifestMap(manifests: StoredManifestMap): StoredManifestMap {
|
|
1824
|
+
const clone: StoredManifestMap = {};
|
|
1825
|
+
for (const [fingerprint, manifest] of Object.entries(manifests)) {
|
|
1826
|
+
clone[fingerprint] = this.cloneManifest(manifest);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
return clone;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private replaceMemoryManifestMap(manifests: StoredManifestMap): void {
|
|
1833
|
+
for (const key of Object.keys(this.memoryManifests)) delete this.memoryManifests[key];
|
|
1834
|
+
for (const [key, manifest] of Object.entries(manifests)) {
|
|
1835
|
+
this.memoryManifests[key] = this.cloneManifest(manifest);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private loadManifest(): CapturedAppManifest | undefined {
|
|
1840
|
+
const manifests = this.loadManifestMap();
|
|
1841
|
+
const manifest = manifests[this.getAppFingerprint()];
|
|
1842
|
+
if (
|
|
1843
|
+
manifest &&
|
|
1844
|
+
(!manifest.appKey ||
|
|
1845
|
+
(this.options.appId !== undefined && manifest.appKey !== this.options.appId))
|
|
1846
|
+
) {
|
|
1847
|
+
manifest.appKey = this.getAppKey();
|
|
1848
|
+
this.saveManifest(manifest);
|
|
1849
|
+
}
|
|
1850
|
+
return manifest ? this.cloneManifest(manifest) : undefined;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private saveManifest(manifest: CapturedAppManifest): void {
|
|
1854
|
+
const manifests = this.loadManifestMap();
|
|
1855
|
+
manifests[this.getAppFingerprint()] = this.cloneManifest(manifest);
|
|
1856
|
+
|
|
1857
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1858
|
+
try {
|
|
1859
|
+
localStorage.setItem(this.manifestStorageKey(), JSON.stringify(manifests));
|
|
1860
|
+
this.replaceMemoryManifestMap(manifests);
|
|
1861
|
+
return;
|
|
1862
|
+
} catch {
|
|
1863
|
+
// Ignore and fall back to memory.
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
this.replaceMemoryManifestMap(manifests);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
private loadPublishDismissedAt(): string | undefined {
|
|
1871
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1872
|
+
try {
|
|
1873
|
+
const raw = localStorage.getItem(this.publishDismissedStorageKey());
|
|
1874
|
+
if (raw) return raw;
|
|
1875
|
+
} catch {
|
|
1876
|
+
// Ignore and fall back to memory.
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
return this.memoryPublishDismissedAt[this.getAppFingerprint()];
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
private savePublishDismissedAt(value: string): void {
|
|
1884
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1885
|
+
try {
|
|
1886
|
+
localStorage.setItem(this.publishDismissedStorageKey(), value);
|
|
1887
|
+
this.memoryPublishDismissedAt[this.getAppFingerprint()] = value;
|
|
1888
|
+
return;
|
|
1889
|
+
} catch {
|
|
1890
|
+
// Ignore and fall back to memory.
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
this.memoryPublishDismissedAt[this.getAppFingerprint()] = value;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
private loadHudCollapsed(): boolean {
|
|
1898
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1899
|
+
try {
|
|
1900
|
+
const raw = localStorage.getItem(this.hudCollapsedStorageKey());
|
|
1901
|
+
if (raw === 'true') return true;
|
|
1902
|
+
if (raw === 'false') return false;
|
|
1903
|
+
} catch {
|
|
1904
|
+
// Ignore and fall back to memory.
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
const raw = this.memoryHudCollapsed[this.getAppFingerprint()];
|
|
1909
|
+
if (raw === 'true') return true;
|
|
1910
|
+
if (raw === 'false') return false;
|
|
1911
|
+
return true;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
private saveHudCollapsed(value: boolean): void {
|
|
1915
|
+
const serialized = value ? 'true' : 'false';
|
|
1916
|
+
|
|
1917
|
+
if (this.options.persist && typeof localStorage !== 'undefined') {
|
|
1918
|
+
try {
|
|
1919
|
+
localStorage.setItem(this.hudCollapsedStorageKey(), serialized);
|
|
1920
|
+
this.memoryHudCollapsed[this.getAppFingerprint()] = serialized;
|
|
1921
|
+
return;
|
|
1922
|
+
} catch {
|
|
1923
|
+
// Ignore and fall back to memory.
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
this.memoryHudCollapsed[this.getAppFingerprint()] = serialized;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
private addPermission(manifest: CapturedAppManifest, permission: string): void {
|
|
1931
|
+
this.addUnique(manifest.permissions, permission);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
private addUnique(values: string[], value: string): void {
|
|
1935
|
+
if (!values.includes(value)) values.push(value);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
private captureTemplateRecord(
|
|
1939
|
+
manifest: CapturedAppManifest,
|
|
1940
|
+
alias: string,
|
|
1941
|
+
template: InlineCredentialTemplate,
|
|
1942
|
+
version: number
|
|
1943
|
+
): void {
|
|
1944
|
+
const now = new Date().toISOString();
|
|
1945
|
+
const existingIndex = manifest.templates.findIndex(record => record.alias === alias);
|
|
1946
|
+
const nextRecord: CapturedTemplateRecord = {
|
|
1947
|
+
alias,
|
|
1948
|
+
template,
|
|
1949
|
+
version,
|
|
1950
|
+
lastUsedAt: now,
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
if (existingIndex >= 0) {
|
|
1954
|
+
manifest.templates[existingIndex] = nextRecord;
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
manifest.templates.push(nextRecord);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
private captureConsentRecord(manifest: CapturedAppManifest, scopes: ConsentRequest): void {
|
|
1962
|
+
const normalized = normalizeConsentRequest(scopes);
|
|
1963
|
+
const key = canonicalConsentScopeString(normalized);
|
|
1964
|
+
const now = new Date().toISOString();
|
|
1965
|
+
const existing = manifest.consentRequests.find(
|
|
1966
|
+
record => canonicalConsentScopeString(record.scopes) === key
|
|
1967
|
+
);
|
|
1968
|
+
|
|
1969
|
+
if (existing) {
|
|
1970
|
+
existing.lastUsedAt = now;
|
|
1971
|
+
if (!existing.reason && typeof scopes.reason === 'string' && scopes.reason) {
|
|
1972
|
+
existing.reason = scopes.reason;
|
|
1973
|
+
}
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const nextRecord: CapturedConsentRecord = {
|
|
1978
|
+
scopes: normalized,
|
|
1979
|
+
...(typeof scopes.reason === 'string' && scopes.reason
|
|
1980
|
+
? { reason: scopes.reason }
|
|
1981
|
+
: {}),
|
|
1982
|
+
lastUsedAt: now,
|
|
1983
|
+
};
|
|
1984
|
+
manifest.consentRequests.push(nextRecord);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
private cloneManifest(manifest: CapturedAppManifest): CapturedAppManifest {
|
|
1988
|
+
return JSON.parse(JSON.stringify(manifest)) as CapturedAppManifest;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
private isManifestPublishable(manifest: CapturedAppManifest): boolean {
|
|
1992
|
+
return manifest.templates.length >= 1 || manifest.permissions.length >= 2;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* True when publish links would point at production LearnCard from a
|
|
1997
|
+
* machine that is almost certainly running LearnCard locally — the one
|
|
1998
|
+
* case where the developer probably wants the override.
|
|
1999
|
+
*/
|
|
2000
|
+
private shouldHintLocalPublishOverride(): boolean {
|
|
2001
|
+
if (this.context.publishOriginPinned) return false;
|
|
2002
|
+
if (this.options.publishOrigin !== DEFAULT_PUBLISH_ORIGIN) return false;
|
|
2003
|
+
if (typeof window === 'undefined') return false;
|
|
2004
|
+
|
|
2005
|
+
const hostname = window.location?.hostname;
|
|
78
2006
|
|
|
79
|
-
|
|
80
|
-
if (typeof record.templateAlias === 'string' && record.templateAlias) {
|
|
81
|
-
const data = record.templateData;
|
|
82
|
-
if (data && typeof data === 'object') {
|
|
83
|
-
const fields = data as Record<string, unknown>;
|
|
84
|
-
const named =
|
|
85
|
-
fields.name ?? fields.achievementName ?? fields.courseName ?? fields.title;
|
|
86
|
-
if (typeof named === 'string' && named) return named;
|
|
87
|
-
}
|
|
88
|
-
return record.templateAlias;
|
|
2007
|
+
return hostname === 'localhost' || hostname === '127.0.0.1';
|
|
89
2008
|
}
|
|
90
2009
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
2010
|
+
public getPublishUrl(manifest: CapturedAppManifest): string;
|
|
2011
|
+
public getPublishUrl(): string | undefined;
|
|
2012
|
+
public getPublishUrl(manifest = this.loadManifest()): string | undefined {
|
|
2013
|
+
if (!manifest) return undefined;
|
|
2014
|
+
const prefix = `${this.options.publishOrigin}/app-store/developer/submit?manifest=`;
|
|
2015
|
+
const url = prefix + encodeManifestForUrl(manifest);
|
|
2016
|
+
if (url.length <= 7000) return url;
|
|
2017
|
+
const compact = { ...manifest };
|
|
2018
|
+
delete compact.suggestedIconDataUrl;
|
|
2019
|
+
return prefix + encodeManifestForUrl(compact);
|
|
100
2020
|
}
|
|
101
2021
|
|
|
102
|
-
|
|
103
|
-
|
|
2022
|
+
private maybeShowPublishPrompt(manifest: CapturedAppManifest): void {
|
|
2023
|
+
if (!this.options.publishPrompt || !this.options.ui || !hasDocument()) return;
|
|
2024
|
+
if (this.publishPromptShown || !this.isManifestPublishable(manifest)) return;
|
|
104
2025
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
2026
|
+
const dismissedAt = this.loadPublishDismissedAt();
|
|
2027
|
+
if (dismissedAt) {
|
|
2028
|
+
const dismissedMs = Date.parse(dismissedAt);
|
|
2029
|
+
if (Number.isFinite(dismissedMs) && Date.now() - dismissedMs < PUBLISH_DISMISS_TTL_MS) {
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
112
2033
|
|
|
113
|
-
|
|
114
|
-
|
|
2034
|
+
this.publishPromptShown = true;
|
|
2035
|
+
this.toast({
|
|
2036
|
+
icon: 'publish',
|
|
2037
|
+
tone: 'publish',
|
|
2038
|
+
persistent: true,
|
|
2039
|
+
dismissible: true,
|
|
2040
|
+
title: 'Ready to publish',
|
|
2041
|
+
action: {
|
|
2042
|
+
label: 'Publish to LearnCard',
|
|
2043
|
+
href: this.getPublishUrl(manifest),
|
|
2044
|
+
},
|
|
2045
|
+
segments: [
|
|
2046
|
+
'Your app works with LearnCard. Put it in front of learners in a few steps.',
|
|
2047
|
+
],
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
115
2050
|
|
|
116
|
-
|
|
117
|
-
|
|
2051
|
+
private refreshPublishPromptLink(manifest: CapturedAppManifest): void {
|
|
2052
|
+
const link = this.stackEl?.querySelector<HTMLAnchorElement>(
|
|
2053
|
+
'.lc-mock-toast--publish .lc-mock-action'
|
|
2054
|
+
);
|
|
2055
|
+
if (link) link.href = this.getPublishUrl(manifest);
|
|
2056
|
+
}
|
|
118
2057
|
|
|
119
|
-
private
|
|
2058
|
+
private describeFeatures(manifest: CapturedAppManifest): HudFeature[] {
|
|
2059
|
+
const features: HudFeature[] = [];
|
|
120
2060
|
|
|
121
|
-
|
|
122
|
-
|
|
2061
|
+
for (const permission of manifest.permissions) {
|
|
2062
|
+
const known = PERMISSION_FEATURES[permission];
|
|
2063
|
+
const feature: HudFeature = known
|
|
2064
|
+
? { ...known }
|
|
2065
|
+
: { icon: 'sparkles', label: permission.replace(/_/g, ' ') };
|
|
123
2066
|
|
|
124
|
-
|
|
125
|
-
|
|
2067
|
+
if (permission === 'send_credential' && manifest.templates.length > 0) {
|
|
2068
|
+
feature.detail = manifest.templates.map(readableTemplateName).join(', ');
|
|
2069
|
+
}
|
|
126
2070
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
2071
|
+
if (permission === 'request_consent') {
|
|
2072
|
+
if (needsConsentSetup(manifest)) {
|
|
2073
|
+
feature.detail = 'Not set up yet';
|
|
2074
|
+
feature.needsSetup = true;
|
|
2075
|
+
} else {
|
|
2076
|
+
feature.detail = this.formatConsentSummary(manifest);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
134
2079
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
namespace: options?.namespace || DEFAULT_NAMESPACE,
|
|
141
|
-
};
|
|
2080
|
+
if (permission === 'launch_feature' && manifest.featuresLaunched.length > 0) {
|
|
2081
|
+
feature.detail = Array.from(
|
|
2082
|
+
new Set(manifest.featuresLaunched.map(readablePath))
|
|
2083
|
+
).join(', ');
|
|
2084
|
+
}
|
|
142
2085
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
did: options?.identity?.did || options?.did || DEFAULT_DID,
|
|
146
|
-
};
|
|
2086
|
+
features.push(feature);
|
|
2087
|
+
}
|
|
147
2088
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
recipient: seed.recipient,
|
|
154
|
-
status: seed.status,
|
|
2089
|
+
if (manifest.templates.length > 0 && !manifest.permissions.includes('send_credential')) {
|
|
2090
|
+
features.push({
|
|
2091
|
+
icon: 'award',
|
|
2092
|
+
label: 'Award credentials',
|
|
2093
|
+
detail: manifest.templates.map(readableTemplateName).join(', '),
|
|
155
2094
|
});
|
|
156
2095
|
}
|
|
157
2096
|
|
|
158
|
-
|
|
159
|
-
|
|
2097
|
+
if (manifest.usedLearnerContext) {
|
|
2098
|
+
features.push({ icon: 'sparkles', label: 'Personalize with the learner’s profile' });
|
|
160
2099
|
}
|
|
161
2100
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Resolve a simulated response for one SDK request. Mirrors the real host:
|
|
167
|
-
* direct actions map 1:1, and `APP_EVENT` is dispatched by its `type`.
|
|
168
|
-
*/
|
|
169
|
-
public handle(action: string, payload?: unknown): Promise<unknown> {
|
|
170
|
-
if (this.destroyed) {
|
|
171
|
-
return Promise.reject({
|
|
172
|
-
code: 'SDK_DESTROYED',
|
|
173
|
-
message: 'Mock host was destroyed before the request completed',
|
|
174
|
-
});
|
|
2101
|
+
if (manifest.usedNotifications) {
|
|
2102
|
+
features.push({ icon: 'bell', label: 'Send notifications' });
|
|
175
2103
|
}
|
|
176
2104
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
2105
|
+
if (manifest.counterKeys.length > 0) {
|
|
2106
|
+
features.push({
|
|
2107
|
+
icon: 'hash',
|
|
2108
|
+
label: 'Track progress',
|
|
2109
|
+
detail: manifest.counterKeys.join(', '),
|
|
2110
|
+
});
|
|
181
2111
|
}
|
|
182
2112
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
this.toast({
|
|
186
|
-
icon: '👤',
|
|
187
|
-
segments: ['In LearnCard, the user would sign in. Returning a mock identity.'],
|
|
188
|
-
});
|
|
189
|
-
return Promise.resolve({
|
|
190
|
-
token: `mock-token-${Date.now()}`,
|
|
191
|
-
user: { ...this.identity },
|
|
192
|
-
});
|
|
2113
|
+
return features;
|
|
2114
|
+
}
|
|
193
2115
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const credentialId = `mock-credential-${Date.now()}-${(this.idSeq += 1)}`;
|
|
197
|
-
this.addCredential({
|
|
198
|
-
name,
|
|
199
|
-
credentialUri: credentialId,
|
|
200
|
-
credential: (payload as { credential?: unknown } | undefined)?.credential,
|
|
201
|
-
});
|
|
202
|
-
this.showClaimToast(name);
|
|
203
|
-
return Promise.resolve({ credentialId, stored: true });
|
|
204
|
-
}
|
|
2116
|
+
private formatConsentSummary(manifest: CapturedAppManifest): string | undefined {
|
|
2117
|
+
if (manifest.consentRequests.length === 0) return undefined;
|
|
205
2118
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return Promise.resolve({ granted: true });
|
|
210
|
-
}
|
|
2119
|
+
const readCategories = new Set<string>();
|
|
2120
|
+
const personalFields = new Set<string>();
|
|
2121
|
+
const writeCategories = new Set<string>();
|
|
211
2122
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return Promise.resolve({ launched: true, featurePath });
|
|
222
|
-
}
|
|
2123
|
+
for (const request of manifest.consentRequests) {
|
|
2124
|
+
request.scopes.read.credentialCategories.forEach(category =>
|
|
2125
|
+
readCategories.add(category)
|
|
2126
|
+
);
|
|
2127
|
+
request.scopes.read.personalFields.forEach(field => personalFields.add(field));
|
|
2128
|
+
request.scopes.write.credentialCategories.forEach(category =>
|
|
2129
|
+
writeCategories.add(category)
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
223
2132
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
segments: held.length
|
|
229
|
-
? ['The user could share ', { b: String(held.length) }, ' credential(s).']
|
|
230
|
-
: [
|
|
231
|
-
'In LearnCard, the user would be asked to share matching credentials. None in mock.',
|
|
232
|
-
],
|
|
233
|
-
});
|
|
234
|
-
return Promise.resolve({
|
|
235
|
-
verifiablePresentation: {
|
|
236
|
-
verifiableCredential: held.map(c => c.credential),
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
}
|
|
2133
|
+
const parts: string[] = [];
|
|
2134
|
+
if (readCategories.size > 0) parts.push(`See ${[...readCategories].join(', ')}`);
|
|
2135
|
+
if (personalFields.size > 0) parts.push(`See ${[...personalFields].join(', ')}`);
|
|
2136
|
+
if (writeCategories.size > 0) parts.push(`Add ${[...writeCategories].join(', ')}`);
|
|
240
2137
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
?.credentialId;
|
|
244
|
-
const found = this.credentials.find(c => c.credentialUri === credentialId);
|
|
245
|
-
this.toast({
|
|
246
|
-
icon: '🔍',
|
|
247
|
-
segments: found
|
|
248
|
-
? ['Sharing ', { b: found.name }, '.']
|
|
249
|
-
: [
|
|
250
|
-
'In LearnCard, the user would be asked to share a credential. Not found in mock.',
|
|
251
|
-
],
|
|
252
|
-
});
|
|
253
|
-
return Promise.resolve({ credential: found?.credential });
|
|
254
|
-
}
|
|
2138
|
+
return parts.join(' · ') || undefined;
|
|
2139
|
+
}
|
|
255
2140
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const recipients = Array.isArray(input?.draftRecipients)
|
|
262
|
-
? (input?.draftRecipients as string[])
|
|
263
|
-
: [];
|
|
264
|
-
for (const recipient of recipients) {
|
|
265
|
-
this.addCredential({
|
|
266
|
-
name: templateId || 'Boost',
|
|
267
|
-
boostUri: templateId,
|
|
268
|
-
recipient,
|
|
269
|
-
status: 'pending',
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
this.toast({
|
|
273
|
-
icon: '📤',
|
|
274
|
-
segments: recipients.length
|
|
275
|
-
? [
|
|
276
|
-
'This would issue to ',
|
|
277
|
-
{ b: String(recipients.length) },
|
|
278
|
-
' recipient(s).',
|
|
279
|
-
]
|
|
280
|
-
: ['In LearnCard, this would open the Send Boost flow.'],
|
|
281
|
-
});
|
|
282
|
-
return Promise.resolve({ issued: true });
|
|
283
|
-
}
|
|
2141
|
+
private copyText(text: string): void {
|
|
2142
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
2143
|
+
navigator.clipboard.writeText(text).catch(() => undefined);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
284
2146
|
|
|
285
|
-
|
|
286
|
-
const opts = (payload ?? {}) as {
|
|
287
|
-
includeCredentials?: boolean;
|
|
288
|
-
format?: string;
|
|
289
|
-
};
|
|
290
|
-
const includeCredentials = opts.includeCredentials !== false;
|
|
291
|
-
const structured = opts.format === 'structured';
|
|
292
|
-
const held = includeCredentials ? this.selfCredentials() : [];
|
|
293
|
-
this.toast({
|
|
294
|
-
icon: '🧠',
|
|
295
|
-
segments: !includeCredentials
|
|
296
|
-
? ['Learner profile requested with credentials excluded.']
|
|
297
|
-
: held.length
|
|
298
|
-
? ['Learner profile: ', { b: String(held.length) }, ' credential(s).']
|
|
299
|
-
: ["In LearnCard, the user's learner profile would load. Empty in mock."],
|
|
300
|
-
});
|
|
301
|
-
const prompt = !includeCredentials
|
|
302
|
-
? 'Mock learner context: credentials were not requested.'
|
|
303
|
-
: held.length
|
|
304
|
-
? `Mock learner context. Credentials held: ${held.map(c => c.name).join(', ')}.`
|
|
305
|
-
: 'Mock learner context: this user has no credentials in standalone mode.';
|
|
306
|
-
return Promise.resolve({
|
|
307
|
-
status: 'ready',
|
|
308
|
-
prompt,
|
|
309
|
-
did: this.identity.did,
|
|
310
|
-
// Matches the real host: `raw` only ships for format: 'structured'.
|
|
311
|
-
...(structured ? { raw: { credentials: held.map(c => c.credential) } } : {}),
|
|
312
|
-
});
|
|
313
|
-
}
|
|
2147
|
+
if (!hasDocument() || !document.body) return;
|
|
314
2148
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
totalCredentials: 0,
|
|
326
|
-
completedCredentials: 0,
|
|
327
|
-
failedCredentials: 0,
|
|
328
|
-
retryCount: 0,
|
|
329
|
-
},
|
|
330
|
-
});
|
|
2149
|
+
const input = document.createElement('textarea');
|
|
2150
|
+
input.value = text;
|
|
2151
|
+
input.setAttribute('readonly', 'true');
|
|
2152
|
+
Object.assign(input.style, {
|
|
2153
|
+
position: 'fixed',
|
|
2154
|
+
opacity: '0',
|
|
2155
|
+
pointerEvents: 'none',
|
|
2156
|
+
});
|
|
2157
|
+
document.body.appendChild(input);
|
|
2158
|
+
input.select();
|
|
331
2159
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
});
|
|
337
|
-
return Promise.resolve({});
|
|
2160
|
+
try {
|
|
2161
|
+
document.execCommand('copy');
|
|
2162
|
+
} catch {
|
|
2163
|
+
// Ignore copy failures in sandboxed/older environments.
|
|
338
2164
|
}
|
|
2165
|
+
|
|
2166
|
+
input.remove();
|
|
339
2167
|
}
|
|
340
2168
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
2169
|
+
private warnIfFingerprintCollides(): void {
|
|
2170
|
+
const manifest = this.loadManifest();
|
|
2171
|
+
const currentAppUrl = this.readAppOrigin();
|
|
344
2172
|
|
|
345
|
-
|
|
346
|
-
this.activeToasts.clear();
|
|
2173
|
+
if (!manifest || !currentAppUrl || manifest.appUrl === currentAppUrl) return;
|
|
347
2174
|
|
|
348
|
-
|
|
349
|
-
|
|
2175
|
+
console.warn(
|
|
2176
|
+
`${MOCK_PREFIX} Existing manifest state for fingerprint "${this.getAppFingerprint()}" ` +
|
|
2177
|
+
`belongs to ${manifest.appUrl}, but this page is ${currentAppUrl}. ` +
|
|
2178
|
+
'Two apps may be sharing mock state. Pass mockOptions.appId to isolate them.'
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
350
2181
|
|
|
351
|
-
|
|
352
|
-
this.
|
|
2182
|
+
private updateManifestHud(manifest?: CapturedAppManifest): void {
|
|
2183
|
+
if (!this.options.ui || !hasDocument() || !document.body) return;
|
|
353
2184
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.
|
|
2185
|
+
const currentManifest = manifest ?? this.loadManifest();
|
|
2186
|
+
if (!currentManifest) {
|
|
2187
|
+
if (this.hudEl) this.hudEl.style.display = 'none';
|
|
2188
|
+
return;
|
|
357
2189
|
}
|
|
358
2190
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
2191
|
+
this.ensureStyles();
|
|
2192
|
+
|
|
2193
|
+
if (!this.hudEl || !document.body.contains(this.hudEl)) {
|
|
2194
|
+
const hud = createEl('div', 'lc-mock-hud');
|
|
2195
|
+
hud.setAttribute('role', 'region');
|
|
2196
|
+
hud.setAttribute('aria-label', 'LearnCard practice mode');
|
|
2197
|
+
document.body.appendChild(hud);
|
|
2198
|
+
this.domNodes.add(hud);
|
|
2199
|
+
this.hudEl = hud;
|
|
2200
|
+
this.hudAnimateNext = true;
|
|
362
2201
|
}
|
|
363
2202
|
|
|
364
|
-
this.
|
|
365
|
-
|
|
2203
|
+
const hud = this.hudEl;
|
|
2204
|
+
hud.style.display = '';
|
|
2205
|
+
hud.replaceChildren(
|
|
2206
|
+
this.confirmingReset
|
|
2207
|
+
? this.renderResetConfirmation()
|
|
2208
|
+
: this.loadHudCollapsed()
|
|
2209
|
+
? this.renderHudPill(currentManifest)
|
|
2210
|
+
: this.renderHudCard(currentManifest)
|
|
2211
|
+
);
|
|
2212
|
+
|
|
2213
|
+
hud.classList.toggle('lc-mock-animate', this.hudAnimateNext);
|
|
2214
|
+
this.hudAnimateNext = false;
|
|
366
2215
|
}
|
|
367
2216
|
|
|
368
|
-
private
|
|
369
|
-
|
|
2217
|
+
private setHudCollapsed(collapsed: boolean, manifest: CapturedAppManifest): void {
|
|
2218
|
+
this.saveHudCollapsed(collapsed);
|
|
2219
|
+
this.hudAnimateNext = true;
|
|
2220
|
+
this.updateManifestHud(manifest);
|
|
2221
|
+
const focusTarget = this.hudEl?.querySelector<HTMLElement>(
|
|
2222
|
+
collapsed ? '.lc-mock-hud-pill' : '.lc-mock-hud-minimize'
|
|
2223
|
+
);
|
|
2224
|
+
focusTarget?.focus({ preventScroll: true });
|
|
2225
|
+
}
|
|
370
2226
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
2227
|
+
private renderHudPill(manifest: CapturedAppManifest): HTMLElement {
|
|
2228
|
+
const count = this.describeFeatures(manifest).length;
|
|
2229
|
+
const pill = createEl('button', 'lc-mock-hud-pill lc-mock-glass');
|
|
2230
|
+
pill.type = 'button';
|
|
2231
|
+
pill.setAttribute('aria-expanded', 'false');
|
|
2232
|
+
pill.setAttribute(
|
|
2233
|
+
'aria-label',
|
|
2234
|
+
`LearnCard practice mode. Your app uses ${plural(count, 'feature')}. Show details.`
|
|
2235
|
+
);
|
|
377
2236
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
this.matchesTemplate(c, { templateAlias, boostUri })
|
|
381
|
-
);
|
|
382
|
-
if (existing) {
|
|
383
|
-
this.toast({
|
|
384
|
-
icon: '✅',
|
|
385
|
-
segments: ['The user already has ', { b: existing.name }, '.'],
|
|
386
|
-
});
|
|
387
|
-
return Promise.resolve({
|
|
388
|
-
credentialUri: existing.credentialUri,
|
|
389
|
-
boostUri: existing.boostUri ?? boostUri,
|
|
390
|
-
alreadyClaimed: true,
|
|
391
|
-
hasCredential: true,
|
|
392
|
-
status: existing.status,
|
|
393
|
-
receivedDate: existing.receivedDate,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
}
|
|
2237
|
+
const mark = createEl('span', 'lc-mock-hud-mark');
|
|
2238
|
+
mark.appendChild(createIcon('mark'));
|
|
397
2239
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
templateAlias,
|
|
401
|
-
boostUri,
|
|
402
|
-
status: 'claimed',
|
|
403
|
-
});
|
|
404
|
-
this.showClaimToast(name);
|
|
405
|
-
return Promise.resolve({
|
|
406
|
-
credentialUri: record.credentialUri,
|
|
407
|
-
boostUri: record.boostUri ?? boostUri,
|
|
408
|
-
alreadyClaimed: false,
|
|
409
|
-
hasCredential: true,
|
|
410
|
-
status: 'claimed',
|
|
411
|
-
receivedDate: record.receivedDate,
|
|
412
|
-
});
|
|
413
|
-
}
|
|
2240
|
+
const label = createEl('span', 'lc-mock-hud-pill-label', 'Practice mode');
|
|
2241
|
+
const badge = createEl('span', 'lc-mock-hud-badge', String(count));
|
|
414
2242
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
this.toast({
|
|
418
|
-
icon: '🔎',
|
|
419
|
-
segments: held
|
|
420
|
-
? ['The user already has ', { b: held.name }, '.']
|
|
421
|
-
: ["Mock: the user doesn't have this credential yet."],
|
|
422
|
-
});
|
|
423
|
-
return Promise.resolve(
|
|
424
|
-
held
|
|
425
|
-
? {
|
|
426
|
-
hasCredential: true,
|
|
427
|
-
credentialUri: held.credentialUri,
|
|
428
|
-
receivedDate: held.receivedDate,
|
|
429
|
-
status: held.status,
|
|
430
|
-
}
|
|
431
|
-
: { hasCredential: false }
|
|
432
|
-
);
|
|
433
|
-
}
|
|
2243
|
+
pill.append(mark, createEl('span', 'lc-mock-dot'), label, badge);
|
|
2244
|
+
pill.addEventListener('click', () => this.setHudCollapsed(false, manifest));
|
|
434
2245
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const match = this.credentials.find(
|
|
438
|
-
c => this.matchesTemplate(c, event) && c.recipient === recipient
|
|
439
|
-
);
|
|
440
|
-
this.toast({
|
|
441
|
-
icon: '🔎',
|
|
442
|
-
segments: match
|
|
443
|
-
? ['Issued to ', { b: recipient }, ' — ', { b: match.status }, '.']
|
|
444
|
-
: ['Mock: not sent to this recipient yet.'],
|
|
445
|
-
});
|
|
446
|
-
return Promise.resolve(
|
|
447
|
-
match
|
|
448
|
-
? {
|
|
449
|
-
sent: true,
|
|
450
|
-
credentialUri: match.credentialUri,
|
|
451
|
-
sentDate: match.sentDate,
|
|
452
|
-
claimedDate: match.claimedDate,
|
|
453
|
-
status: match.status,
|
|
454
|
-
}
|
|
455
|
-
: { sent: false }
|
|
456
|
-
);
|
|
457
|
-
}
|
|
2246
|
+
return pill;
|
|
2247
|
+
}
|
|
458
2248
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const limit =
|
|
462
|
-
typeof event.limit === 'number' && event.limit > 0 ? event.limit : undefined;
|
|
463
|
-
// Cursor pagination: the cursor is the offset of the next record,
|
|
464
|
-
// issued by the previous page so callers can walk the full list.
|
|
465
|
-
const parsedCursor =
|
|
466
|
-
typeof event.cursor === 'string' ? Number.parseInt(event.cursor, 10) : 0;
|
|
467
|
-
const offset = Number.isFinite(parsedCursor) && parsedCursor > 0 ? parsedCursor : 0;
|
|
468
|
-
const page = limit ? matched.slice(offset, offset + limit) : matched.slice(offset);
|
|
469
|
-
const nextOffset = offset + page.length;
|
|
470
|
-
const hasMore = nextOffset < matched.length;
|
|
471
|
-
this.toast({
|
|
472
|
-
icon: '👥',
|
|
473
|
-
segments: [
|
|
474
|
-
'In LearnCard, this lists recipients. ',
|
|
475
|
-
{ b: String(matched.length) },
|
|
476
|
-
' in mock.',
|
|
477
|
-
],
|
|
478
|
-
});
|
|
479
|
-
return Promise.resolve({
|
|
480
|
-
records: page.map(c => this.toRecipientRecord(c)),
|
|
481
|
-
hasMore,
|
|
482
|
-
...(hasMore ? { cursor: String(nextOffset) } : {}),
|
|
483
|
-
total: matched.length,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
2249
|
+
private renderHudCard(manifest: CapturedAppManifest): HTMLElement {
|
|
2250
|
+
const card = createEl('div', 'lc-mock-hud-card lc-mock-glass');
|
|
486
2251
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const text = [title, body].filter(Boolean).join(' — ');
|
|
491
|
-
this.toast({
|
|
492
|
-
icon: '🔔',
|
|
493
|
-
segments: text
|
|
494
|
-
? ['The user would be notified: ', { b: text }]
|
|
495
|
-
: ['In LearnCard, the user would receive a notification.'],
|
|
496
|
-
});
|
|
497
|
-
return Promise.resolve({ sent: true });
|
|
498
|
-
}
|
|
2252
|
+
const header = createEl('div', 'lc-mock-hud-header');
|
|
2253
|
+
const mark = createEl('span', 'lc-mock-hud-mark lc-mock-hud-mark--lg');
|
|
2254
|
+
mark.appendChild(createIcon('mark'));
|
|
499
2255
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
icon: '🔢',
|
|
508
|
-
segments: ['Counter ', { b: key }, ' → ', { b: String(next) }, '.'],
|
|
509
|
-
});
|
|
510
|
-
return Promise.resolve({ key, previousValue: previous, newValue: next });
|
|
511
|
-
}
|
|
2256
|
+
const heading = createEl('div', 'lc-mock-hud-heading');
|
|
2257
|
+
const title = createEl('div', 'lc-mock-hud-title');
|
|
2258
|
+
title.append(createEl('span', undefined, 'Practice mode'), createEl('span', 'lc-mock-dot'));
|
|
2259
|
+
heading.append(
|
|
2260
|
+
title,
|
|
2261
|
+
createEl('div', 'lc-mock-hud-subtitle', 'LearnCard actions are simulated here.')
|
|
2262
|
+
);
|
|
512
2263
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
2264
|
+
const minimize = createEl('button', 'lc-mock-hud-minimize');
|
|
2265
|
+
minimize.type = 'button';
|
|
2266
|
+
minimize.setAttribute('aria-label', 'Minimize');
|
|
2267
|
+
minimize.setAttribute('aria-expanded', 'true');
|
|
2268
|
+
minimize.title = 'Minimize';
|
|
2269
|
+
minimize.appendChild(createIcon('chevronDown'));
|
|
2270
|
+
minimize.addEventListener('click', () => this.setHudCollapsed(true, manifest));
|
|
2271
|
+
|
|
2272
|
+
header.append(mark, heading, minimize);
|
|
2273
|
+
card.appendChild(header);
|
|
2274
|
+
|
|
2275
|
+
card.appendChild(this.renderHudApp(manifest));
|
|
2276
|
+
|
|
2277
|
+
const features = this.describeFeatures(manifest);
|
|
2278
|
+
card.appendChild(
|
|
2279
|
+
createEl(
|
|
2280
|
+
'div',
|
|
2281
|
+
'lc-mock-hud-section',
|
|
2282
|
+
features.length > 0 ? 'What your app uses' : 'Nothing yet'
|
|
2283
|
+
)
|
|
2284
|
+
);
|
|
527
2285
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
2286
|
+
const list = createEl('ul', 'lc-mock-hud-group lc-mock-hud-list');
|
|
2287
|
+
if (features.length === 0) {
|
|
2288
|
+
list.appendChild(
|
|
2289
|
+
createEl(
|
|
2290
|
+
'li',
|
|
2291
|
+
'lc-mock-hud-empty',
|
|
2292
|
+
'Use a LearnCard feature in your app and it will show up here.'
|
|
2293
|
+
)
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
for (const feature of features) {
|
|
2298
|
+
const row = createEl('li', 'lc-mock-hud-row');
|
|
2299
|
+
const glyph = createEl('span', 'lc-mock-hud-glyph');
|
|
2300
|
+
glyph.appendChild(createIcon(feature.icon));
|
|
2301
|
+
|
|
2302
|
+
const text = createEl('div', 'lc-mock-hud-row-text');
|
|
2303
|
+
text.appendChild(createEl('div', 'lc-mock-hud-row-label', feature.label));
|
|
2304
|
+
if (feature.detail) {
|
|
2305
|
+
const detail = createEl(
|
|
2306
|
+
'div',
|
|
2307
|
+
`lc-mock-hud-row-detail${feature.needsSetup ? ' is-setup' : ''}`,
|
|
2308
|
+
feature.detail
|
|
2309
|
+
);
|
|
2310
|
+
detail.title = feature.needsSetup
|
|
2311
|
+
? 'You can choose what to ask for when you publish.'
|
|
2312
|
+
: feature.detail;
|
|
2313
|
+
text.appendChild(detail);
|
|
541
2314
|
}
|
|
542
2315
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const record = this.addCredential({
|
|
550
|
-
name: sessionTitle,
|
|
551
|
-
boostUri: sessionBoostUri,
|
|
552
|
-
status: 'claimed',
|
|
553
|
-
});
|
|
554
|
-
this.toast({
|
|
555
|
-
icon: '✅',
|
|
556
|
-
segments: [
|
|
557
|
-
'In LearnCard, an AI session credential would be saved: ',
|
|
558
|
-
{ b: sessionTitle },
|
|
559
|
-
],
|
|
560
|
-
});
|
|
2316
|
+
const status = createEl(
|
|
2317
|
+
'span',
|
|
2318
|
+
feature.needsSetup ? 'lc-mock-hud-setup-dot' : 'lc-mock-hud-check'
|
|
2319
|
+
);
|
|
2320
|
+
if (feature.needsSetup) status.setAttribute('aria-label', 'Needs setup');
|
|
2321
|
+
else status.appendChild(createIcon('check'));
|
|
561
2322
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
2323
|
+
row.append(glyph, text, status);
|
|
2324
|
+
list.appendChild(row);
|
|
2325
|
+
}
|
|
2326
|
+
card.appendChild(list);
|
|
2327
|
+
|
|
2328
|
+
card.appendChild(this.renderHudFooter(manifest));
|
|
2329
|
+
const reset = createEl('button', 'lc-mock-hud-reset', 'Start over');
|
|
2330
|
+
reset.type = 'button';
|
|
2331
|
+
reset.addEventListener('click', () => {
|
|
2332
|
+
this.confirmingReset = true;
|
|
2333
|
+
this.updateManifestHud();
|
|
2334
|
+
this.hudEl?.querySelector<HTMLButtonElement>('.lc-mock-hud-reset-cancel')?.focus();
|
|
2335
|
+
});
|
|
2336
|
+
card.appendChild(reset);
|
|
572
2337
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
2338
|
+
if (this.shouldHintLocalPublishOverride()) {
|
|
2339
|
+
const hint = createEl('div', 'lc-mock-hud-publish-hint', 'Local LearnCard? Add ');
|
|
2340
|
+
hint.appendChild(
|
|
2341
|
+
createEl('code', undefined, '?lc_publish_override=http://localhost:3000')
|
|
2342
|
+
);
|
|
2343
|
+
card.appendChild(hint);
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
return card;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
private renderResetConfirmation(): HTMLElement {
|
|
2350
|
+
const card = createEl('div', 'lc-mock-hud-card lc-mock-glass');
|
|
2351
|
+
const body = createEl('div', 'lc-mock-hud-confirm');
|
|
2352
|
+
body.append(
|
|
2353
|
+
createEl('div', 'lc-mock-hud-title', 'Start over with this app?'),
|
|
2354
|
+
createEl(
|
|
2355
|
+
'p',
|
|
2356
|
+
undefined,
|
|
2357
|
+
"This clears what LearnCard recorded here — features, practice credentials, and counters. Your code isn't touched, and nothing already in LearnCard changes."
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
const actions = createEl('div', 'lc-mock-hud-confirm-actions');
|
|
2361
|
+
const cancel = createEl('button', 'lc-mock-hud-reset-cancel', 'Cancel');
|
|
2362
|
+
const confirm = createEl('button', 'lc-mock-hud-reset-confirm', 'Start Over');
|
|
2363
|
+
cancel.type = confirm.type = 'button';
|
|
2364
|
+
const restore = (): void => {
|
|
2365
|
+
this.confirmingReset = false;
|
|
2366
|
+
this.updateManifestHud();
|
|
2367
|
+
this.hudEl?.querySelector<HTMLButtonElement>('.lc-mock-hud-reset')?.focus();
|
|
2368
|
+
};
|
|
2369
|
+
cancel.addEventListener('click', restore);
|
|
2370
|
+
card.addEventListener('keydown', event => {
|
|
2371
|
+
if (event.key === 'Escape') {
|
|
2372
|
+
event.preventDefault();
|
|
2373
|
+
restore();
|
|
580
2374
|
}
|
|
2375
|
+
});
|
|
2376
|
+
confirm.addEventListener('click', () => this.resetPracticeMode());
|
|
2377
|
+
actions.append(cancel, confirm);
|
|
2378
|
+
body.appendChild(actions);
|
|
2379
|
+
card.appendChild(body);
|
|
2380
|
+
return card;
|
|
2381
|
+
}
|
|
581
2382
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
2383
|
+
private renderHudApp(manifest: CapturedAppManifest): HTMLElement {
|
|
2384
|
+
const app = createEl('div', 'lc-mock-hud-group lc-mock-hud-app');
|
|
2385
|
+
const name = manifest.suggestedName || 'Untitled app';
|
|
2386
|
+
|
|
2387
|
+
const avatar = createEl('span', 'lc-mock-hud-avatar');
|
|
2388
|
+
const initial = name.trim().charAt(0).toUpperCase() || 'A';
|
|
2389
|
+
const icon = manifest.suggestedIconDataUrl ?? manifest.suggestedIconUrl;
|
|
2390
|
+
if (icon) {
|
|
2391
|
+
const img = createEl('img');
|
|
2392
|
+
img.alt = '';
|
|
2393
|
+
img.src = icon;
|
|
2394
|
+
img.addEventListener('error', () => {
|
|
2395
|
+
img.remove();
|
|
2396
|
+
avatar.textContent = initial;
|
|
2397
|
+
});
|
|
2398
|
+
avatar.appendChild(img);
|
|
2399
|
+
} else {
|
|
2400
|
+
avatar.textContent = initial;
|
|
588
2401
|
}
|
|
2402
|
+
|
|
2403
|
+
const text = createEl('div', 'lc-mock-hud-row-text');
|
|
2404
|
+
text.appendChild(createEl('div', 'lc-mock-hud-app-name', name));
|
|
2405
|
+
|
|
2406
|
+
const host = readHost(manifest.appUrl);
|
|
2407
|
+
if (host) text.appendChild(createEl('div', 'lc-mock-hud-row-detail', host));
|
|
2408
|
+
|
|
2409
|
+
app.append(avatar, text);
|
|
2410
|
+
return app;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
private renderHudFooter(manifest: CapturedAppManifest): HTMLElement {
|
|
2414
|
+
const publishUrl = this.getPublishUrl(manifest);
|
|
2415
|
+
const footer = createEl('div', 'lc-mock-hud-footer');
|
|
2416
|
+
|
|
2417
|
+
const publish = createEl('a', 'lc-mock-hud-publish');
|
|
2418
|
+
publish.href = publishUrl;
|
|
2419
|
+
publish.target = '_blank';
|
|
2420
|
+
publish.rel = 'noopener';
|
|
2421
|
+
publish.append(createEl('span', undefined, 'Publish app'), createIcon('open'));
|
|
2422
|
+
|
|
2423
|
+
const copy = createEl('button', 'lc-mock-hud-copy');
|
|
2424
|
+
copy.type = 'button';
|
|
2425
|
+
copy.title = 'Copy publish link';
|
|
2426
|
+
copy.setAttribute('aria-label', 'Copy publish link');
|
|
2427
|
+
copy.appendChild(createIcon('copy'));
|
|
2428
|
+
copy.addEventListener('click', () => {
|
|
2429
|
+
this.copyText(publishUrl);
|
|
2430
|
+
copy.classList.add('is-copied');
|
|
2431
|
+
copy.replaceChildren(createIcon('check'));
|
|
2432
|
+
copy.title = 'Link copied';
|
|
2433
|
+
copy.setAttribute('aria-label', 'Link copied');
|
|
2434
|
+
|
|
2435
|
+
const reset = setTimeout(() => {
|
|
2436
|
+
this.exitTimers.delete(reset);
|
|
2437
|
+
copy.classList.remove('is-copied');
|
|
2438
|
+
copy.replaceChildren(createIcon('copy'));
|
|
2439
|
+
copy.title = 'Copy publish link';
|
|
2440
|
+
copy.setAttribute('aria-label', 'Copy publish link');
|
|
2441
|
+
}, 1800);
|
|
2442
|
+
this.exitTimers.add(reset);
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
footer.append(publish, copy);
|
|
2446
|
+
return footer;
|
|
589
2447
|
}
|
|
590
2448
|
|
|
591
2449
|
private nextUri(prefix: string): string {
|
|
@@ -661,7 +2519,7 @@ export class MockHost {
|
|
|
661
2519
|
}
|
|
662
2520
|
|
|
663
2521
|
private counterStorageKey(): string {
|
|
664
|
-
return `${this.options.namespace}:counters`;
|
|
2522
|
+
return `${this.options.namespace}:counters:${this.getAppFingerprint()}`;
|
|
665
2523
|
}
|
|
666
2524
|
|
|
667
2525
|
private loadCounters(): Record<string, StoredCounter> {
|
|
@@ -717,6 +2575,9 @@ export class MockHost {
|
|
|
717
2575
|
}
|
|
718
2576
|
|
|
719
2577
|
private announce(): void {
|
|
2578
|
+
this.warnIfFingerprintCollides();
|
|
2579
|
+
this.updateManifestHud();
|
|
2580
|
+
|
|
720
2581
|
if (!this.options.log) return;
|
|
721
2582
|
// eslint-disable-next-line no-console
|
|
722
2583
|
console.log(
|
|
@@ -726,22 +2587,44 @@ export class MockHost {
|
|
|
726
2587
|
);
|
|
727
2588
|
}
|
|
728
2589
|
|
|
729
|
-
private showClaimToast(credentialName: string): void {
|
|
2590
|
+
private showClaimToast(credentialName: string, templateVersion?: number): void {
|
|
2591
|
+
const versionSuffix =
|
|
2592
|
+
typeof templateVersion === 'number' && templateVersion > 1
|
|
2593
|
+
? ` Template updated to v${templateVersion}.`
|
|
2594
|
+
: '';
|
|
2595
|
+
|
|
730
2596
|
this.toast({
|
|
731
|
-
icon: '
|
|
2597
|
+
icon: 'award',
|
|
2598
|
+
tone: 'positive',
|
|
732
2599
|
ttl: 5200,
|
|
733
|
-
segments: [
|
|
2600
|
+
segments: [
|
|
2601
|
+
'In LearnCard, the learner would receive ',
|
|
2602
|
+
{ b: credentialName },
|
|
2603
|
+
`.${versionSuffix}`,
|
|
2604
|
+
],
|
|
734
2605
|
});
|
|
735
2606
|
}
|
|
736
2607
|
|
|
737
|
-
private
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
:
|
|
744
|
-
|
|
2608
|
+
private showConsentToast(payload: unknown, redirectIgnored: boolean): void {
|
|
2609
|
+
const scopes = (payload as { scopes?: unknown } | undefined)?.scopes;
|
|
2610
|
+
const contractUri = (payload as { contractUri?: unknown } | undefined)?.contractUri;
|
|
2611
|
+
|
|
2612
|
+
if (!scopes && !contractUri) {
|
|
2613
|
+
this.toast({
|
|
2614
|
+
icon: 'shield',
|
|
2615
|
+
tone: 'warning',
|
|
2616
|
+
ttl: 8000,
|
|
2617
|
+
title: 'Choose what to ask for',
|
|
2618
|
+
segments: [
|
|
2619
|
+
'Approved here, but once published LearnCard needs to know what to ask the learner. You can choose it when you publish, or say it in code: ',
|
|
2620
|
+
{ b: "requestConsent({ read: { credentialCategories: ['Achievement'] } })" },
|
|
2621
|
+
],
|
|
2622
|
+
});
|
|
2623
|
+
} else {
|
|
2624
|
+
const segments = buildConsentScopeToastSegments(payload);
|
|
2625
|
+
if (redirectIgnored) segments.push(' Redirect skipped in practice.');
|
|
2626
|
+
this.toast({ icon: 'shield', tone: 'positive', segments });
|
|
2627
|
+
}
|
|
745
2628
|
|
|
746
2629
|
if (redirectIgnored) {
|
|
747
2630
|
this.note(
|
|
@@ -767,16 +2650,20 @@ export class MockHost {
|
|
|
767
2650
|
|
|
768
2651
|
const tone = spec.tone ?? 'default';
|
|
769
2652
|
const ttl = spec.ttl ?? 4200;
|
|
2653
|
+
const persistent = Boolean(spec.persistent);
|
|
770
2654
|
const text = spec.segments.map(s => (typeof s === 'string' ? s : s.b)).join('');
|
|
771
|
-
const
|
|
2655
|
+
const actionKey = spec.action ? `|${spec.action.href}|${spec.action.label}` : '';
|
|
2656
|
+
const key = `${tone}|${spec.icon}|${spec.title ?? ''}|${text}${actionKey}`;
|
|
772
2657
|
|
|
773
2658
|
const existing = this.activeToasts.get(key);
|
|
774
2659
|
if (existing) {
|
|
775
2660
|
existing.count += 1;
|
|
776
2661
|
existing.countEl.textContent = `×${existing.count}`;
|
|
777
2662
|
existing.countEl.style.display = '';
|
|
778
|
-
|
|
779
|
-
existing.
|
|
2663
|
+
existing.countEl.classList.remove('lc-mock-bump');
|
|
2664
|
+
void existing.countEl.offsetWidth;
|
|
2665
|
+
existing.countEl.classList.add('lc-mock-bump');
|
|
2666
|
+
this.scheduleToastDismiss(key);
|
|
780
2667
|
return;
|
|
781
2668
|
}
|
|
782
2669
|
|
|
@@ -784,42 +2671,103 @@ export class MockHost {
|
|
|
784
2671
|
if (!stack) return;
|
|
785
2672
|
this.ensureStyles();
|
|
786
2673
|
|
|
787
|
-
const toast =
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
2674
|
+
const toast = createEl('div', `lc-mock-toast lc-mock-glass lc-mock-toast--${tone}`);
|
|
2675
|
+
if (spec.dismissible) toast.classList.add('lc-mock-toast--dismissible');
|
|
2676
|
+
|
|
2677
|
+
const tile = createEl('span', 'lc-mock-tile');
|
|
2678
|
+
tile.appendChild(createIcon(spec.icon));
|
|
2679
|
+
|
|
2680
|
+
const content = createEl('div', 'lc-mock-content');
|
|
2681
|
+
|
|
2682
|
+
const meta = createEl('div', 'lc-mock-meta');
|
|
2683
|
+
const countEl = createEl('span', 'lc-mock-count');
|
|
2684
|
+
countEl.style.display = 'none';
|
|
2685
|
+
meta.append(
|
|
2686
|
+
createEl('span', 'lc-mock-meta-name', 'LearnCard'),
|
|
2687
|
+
createEl('span', 'lc-mock-chip', 'Practice'),
|
|
2688
|
+
countEl
|
|
2689
|
+
);
|
|
2690
|
+
content.appendChild(meta);
|
|
2691
|
+
|
|
2692
|
+
if (spec.title) content.appendChild(createEl('div', 'lc-mock-title', spec.title));
|
|
2693
|
+
|
|
2694
|
+
const body = createEl('div', 'lc-mock-body');
|
|
802
2695
|
for (const seg of spec.segments) {
|
|
803
2696
|
if (typeof seg === 'string') {
|
|
804
2697
|
body.append(seg);
|
|
805
2698
|
} else {
|
|
806
|
-
|
|
807
|
-
strong.textContent = seg.b;
|
|
808
|
-
body.appendChild(strong);
|
|
2699
|
+
body.appendChild(createEl('strong', undefined, seg.b));
|
|
809
2700
|
}
|
|
810
2701
|
}
|
|
2702
|
+
content.appendChild(body);
|
|
2703
|
+
|
|
2704
|
+
if (spec.action) {
|
|
2705
|
+
const action = createEl('a', 'lc-mock-action');
|
|
2706
|
+
action.href = spec.action.href;
|
|
2707
|
+
action.target = '_blank';
|
|
2708
|
+
action.rel = 'noopener';
|
|
2709
|
+
action.append(createEl('span', undefined, spec.action.label), createIcon('open'));
|
|
2710
|
+
content.appendChild(action);
|
|
2711
|
+
}
|
|
811
2712
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
2713
|
+
toast.append(tile, content);
|
|
2714
|
+
|
|
2715
|
+
if (spec.dismissible) {
|
|
2716
|
+
const close = createEl('button', 'lc-mock-close');
|
|
2717
|
+
close.type = 'button';
|
|
2718
|
+
close.setAttribute('aria-label', 'Dismiss');
|
|
2719
|
+
close.appendChild(createIcon('close'));
|
|
2720
|
+
close.addEventListener('click', () => {
|
|
2721
|
+
if (tone === 'publish') {
|
|
2722
|
+
this.savePublishDismissedAt(new Date().toISOString());
|
|
2723
|
+
}
|
|
2724
|
+
this.dismissToast(key);
|
|
2725
|
+
});
|
|
2726
|
+
toast.appendChild(close);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
toast.addEventListener('mouseenter', () => {
|
|
2730
|
+
const entry = this.activeToasts.get(key);
|
|
2731
|
+
if (entry?.timeoutId) {
|
|
2732
|
+
clearTimeout(entry.timeoutId);
|
|
2733
|
+
entry.timeoutId = null;
|
|
2734
|
+
}
|
|
2735
|
+
});
|
|
2736
|
+
toast.addEventListener('mouseleave', () => this.scheduleToastDismiss(key));
|
|
816
2737
|
|
|
817
|
-
toast.append(badge, body);
|
|
818
2738
|
stack.appendChild(toast);
|
|
819
2739
|
this.domNodes.add(toast);
|
|
820
2740
|
|
|
821
|
-
|
|
822
|
-
|
|
2741
|
+
this.activeToasts.set(key, {
|
|
2742
|
+
node: toast,
|
|
2743
|
+
timeoutId: null,
|
|
2744
|
+
count: 1,
|
|
2745
|
+
countEl,
|
|
2746
|
+
ttl,
|
|
2747
|
+
persistent,
|
|
2748
|
+
});
|
|
2749
|
+
this.scheduleToastDismiss(key);
|
|
2750
|
+
this.refreshToastVisibility();
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
private scheduleToastDismiss(key: string): void {
|
|
2754
|
+
const entry = this.activeToasts.get(key);
|
|
2755
|
+
if (!entry) return;
|
|
2756
|
+
|
|
2757
|
+
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
|
2758
|
+
entry.timeoutId = entry.persistent
|
|
2759
|
+
? null
|
|
2760
|
+
: setTimeout(() => this.dismissToast(key), entry.ttl);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
private refreshToastVisibility(): void {
|
|
2764
|
+
const transient = Array.from(this.activeToasts.values()).filter(entry => !entry.persistent);
|
|
2765
|
+
const hiddenCount = Math.max(0, transient.length - MAX_VISIBLE_TOASTS);
|
|
2766
|
+
|
|
2767
|
+
transient.forEach((entry, index) => {
|
|
2768
|
+
entry.node.classList.toggle('lc-mock-toast--hidden', index < hiddenCount);
|
|
2769
|
+
entry.node.classList.toggle('lc-mock-toast--older', index < transient.length - 1);
|
|
2770
|
+
});
|
|
823
2771
|
}
|
|
824
2772
|
|
|
825
2773
|
private dismissToast(key: string): void {
|
|
@@ -827,7 +2775,8 @@ export class MockHost {
|
|
|
827
2775
|
if (!entry) return;
|
|
828
2776
|
|
|
829
2777
|
this.activeToasts.delete(key);
|
|
830
|
-
clearTimeout(entry.timeoutId);
|
|
2778
|
+
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
|
2779
|
+
this.refreshToastVisibility();
|
|
831
2780
|
|
|
832
2781
|
const { node } = entry;
|
|
833
2782
|
node.classList.add('lc-mock-out');
|
|
@@ -843,8 +2792,9 @@ export class MockHost {
|
|
|
843
2792
|
if (!document.body) return null;
|
|
844
2793
|
if (this.stackEl && document.body.contains(this.stackEl)) return this.stackEl;
|
|
845
2794
|
|
|
846
|
-
const stack =
|
|
847
|
-
stack.
|
|
2795
|
+
const stack = createEl('div', 'lc-mock-stack');
|
|
2796
|
+
stack.setAttribute('role', 'status');
|
|
2797
|
+
stack.setAttribute('aria-live', 'polite');
|
|
848
2798
|
document.body.appendChild(stack);
|
|
849
2799
|
this.stackEl = stack;
|
|
850
2800
|
return stack;
|
|
@@ -854,33 +2804,7 @@ export class MockHost {
|
|
|
854
2804
|
if (!this.options.ui || !hasDocument() || this.styleEl || !document.head) return;
|
|
855
2805
|
|
|
856
2806
|
const style = document.createElement('style');
|
|
857
|
-
style.textContent =
|
|
858
|
-
@keyframes lc-mock-in { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }
|
|
859
|
-
@keyframes lc-mock-out { to { opacity: 0; transform: translateY(6px); } }
|
|
860
|
-
.lc-mock-stack {
|
|
861
|
-
position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
|
|
862
|
-
display: flex; flex-direction: column; gap: 10px; align-items: flex-end;
|
|
863
|
-
pointer-events: none; max-width: min(360px, calc(100vw - 40px));
|
|
864
|
-
}
|
|
865
|
-
.lc-mock-toast {
|
|
866
|
-
pointer-events: auto; width: 100%; box-sizing: border-box; padding: 11px 14px; border-radius: 14px;
|
|
867
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13.5px;
|
|
868
|
-
line-height: 1.45; box-shadow: 0 10px 30px rgba(24,34,78,0.22); animation: lc-mock-in 180ms cubic-bezier(0.2,0.8,0.2,1);
|
|
869
|
-
}
|
|
870
|
-
.lc-mock-toast.lc-mock-out { animation: lc-mock-out 180ms ease-in forwards; }
|
|
871
|
-
.lc-mock-toast--default { background: #18224E; color: #fff; }
|
|
872
|
-
.lc-mock-toast--positive { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
|
|
873
|
-
.lc-mock-badge {
|
|
874
|
-
display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px;
|
|
875
|
-
font-size: 11px; letter-spacing: 0.02em; text-transform: uppercase; opacity: 0.72;
|
|
876
|
-
}
|
|
877
|
-
.lc-mock-badge-name { font-weight: 600; }
|
|
878
|
-
.lc-mock-pill { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 999px; background: rgba(255,255,255,0.16); }
|
|
879
|
-
.lc-mock-toast--positive .lc-mock-pill { background: rgba(6,95,70,0.12); }
|
|
880
|
-
.lc-mock-body strong { font-weight: 700; }
|
|
881
|
-
.lc-mock-count { margin-left: 6px; font-weight: 700; opacity: 0.75; }
|
|
882
|
-
`.trim();
|
|
883
|
-
|
|
2807
|
+
style.textContent = MOCK_STYLES;
|
|
884
2808
|
document.head.appendChild(style);
|
|
885
2809
|
this.styleEl = style;
|
|
886
2810
|
}
|