@learncard/partner-connect 0.5.1 → 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/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 type { MockHostOptions } from './types';
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: string;
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
- // Template-based issuance (APP_EVENT / send-credential).
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
- // Raw verifiable credential.
92
- const credential = (record.credential ?? record) as Record<string, unknown>;
93
- if (credential && typeof credential === 'object') {
94
- const subject = credential.credentialSubject as Record<string, unknown> | undefined;
95
- const achievement = subject?.achievement as Record<string, unknown> | undefined;
96
- if (achievement && typeof achievement.name === 'string' && achievement.name) {
97
- return achievement.name;
98
- }
99
- if (typeof credential.name === 'string' && credential.name) return credential.name;
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
- return 'a credential';
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
- * Simulates the LearnCard host for a single {@link PartnerConnect} instance.
107
- * All public methods route through `handle`, keeping the mock behind the same
108
- * `(action, payload)` contract the real host answers over `postMessage`.
109
- */
110
- export class MockHost {
111
- private readonly options: ResolvedMockOptions;
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
- /** In-memory counter fallback used when persistence is off/unavailable. */
114
- private readonly memoryCounters = new Map<string, StoredCounter>();
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
- /** Session-scoped credential store; reads reflect writes and seeds. */
117
- private readonly credentials: MockCredential[] = [];
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 readonly identity: { did: string; [key: string]: unknown };
2058
+ private describeFeatures(manifest: CapturedAppManifest): HudFeature[] {
2059
+ const features: HudFeature[] = [];
120
2060
 
121
- /** DOM nodes injected for visual feedback, tracked for cleanup. */
122
- private readonly domNodes = new Set<HTMLElement>();
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
- /** The app's single AI Topic, created lazily by the first AI session. */
125
- private aiTopic: { topicUri: string; topicCredentialUri: string } | null = null;
2067
+ if (permission === 'send_credential' && manifest.templates.length > 0) {
2068
+ feature.detail = manifest.templates.map(readableTemplateName).join(', ');
2069
+ }
126
2070
 
127
- private styleEl: HTMLStyleElement | null = null;
128
- private stackEl: HTMLElement | null = null;
129
- private readonly activeToasts = new Map<string, ActiveToast>();
130
- /** Pending exit-animation timers, tracked so `destroy()` can cancel them. */
131
- private readonly exitTimers = new Set<ReturnType<typeof setTimeout>>();
132
- private idSeq = 0;
133
- private destroyed = false;
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
- constructor(options?: MockHostOptions) {
136
- this.options = {
137
- ui: options?.ui ?? true,
138
- log: options?.log ?? true,
139
- persist: options?.persist ?? true,
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
- this.identity = {
144
- ...(options?.identity ?? {}),
145
- did: options?.identity?.did || options?.did || DEFAULT_DID,
146
- };
2086
+ features.push(feature);
2087
+ }
147
2088
 
148
- for (const seed of options?.credentials ?? []) {
149
- this.addCredential({
150
- name: seed.name || seed.templateAlias || 'Mock Credential',
151
- templateAlias: seed.templateAlias,
152
- boostUri: seed.boostUri,
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
- for (const [key, value] of Object.entries(options?.counters ?? {})) {
159
- if (this.readCounter(key) === undefined) this.writeCounter(key, value);
2097
+ if (manifest.usedLearnerContext) {
2098
+ features.push({ icon: 'sparkles', label: 'Personalize with the learner’s profile' });
160
2099
  }
161
2100
 
162
- this.announce();
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
- this.log(action, payload);
178
-
179
- if (action === 'APP_EVENT') {
180
- return this.handleAppEvent(payload as Record<string, unknown>);
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
- switch (action) {
184
- case 'REQUEST_IDENTITY':
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
- case 'SEND_CREDENTIAL': {
195
- const name = readAchievementName(payload);
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
- case 'REQUEST_CONSENT': {
207
- const redirect = Boolean((payload as { redirect?: boolean } | undefined)?.redirect);
208
- this.showConsentBanner(redirect);
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
- case 'LAUNCH_FEATURE': {
213
- const featurePath =
214
- (payload as { featurePath?: string } | undefined)?.featurePath ?? '';
215
- this.toast({
216
- icon: '🚀',
217
- segments: featurePath
218
- ? ['In LearnCard, this would open ', { b: featurePath }, '.']
219
- : ['In LearnCard, this would open a feature screen.'],
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
- case 'ASK_CREDENTIAL_SEARCH': {
225
- const held = this.selfCredentials();
226
- this.toast({
227
- icon: '🔍',
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
- case 'ASK_CREDENTIAL_SPECIFIC': {
242
- const credentialId = (payload as { credentialId?: string } | undefined)
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
- case 'INITIATE_TEMPLATE_ISSUE': {
257
- const input = payload as
258
- | { templateId?: string; draftRecipients?: string[] }
259
- | undefined;
260
- const templateId = input?.templateId ?? '';
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
- case 'REQUEST_LEARNER_CONTEXT': {
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
- case 'GET_SYNC_STATUS':
316
- this.toast({
317
- icon: '🔄',
318
- segments: [
319
- 'In LearnCard, this reports data sync progress. Mock reports ready.',
320
- ],
321
- });
322
- return Promise.resolve({
323
- status: 'ready',
324
- progress: {
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
- default:
333
- this.toast({
334
- icon: '✨',
335
- segments: ['In LearnCard, this would run ', { b: action }, '.'],
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
- /** Tear down injected UI and clear in-memory state. */
342
- public destroy(): void {
343
- this.destroyed = true;
2169
+ private warnIfFingerprintCollides(): void {
2170
+ const manifest = this.loadManifest();
2171
+ const currentAppUrl = this.readAppOrigin();
344
2172
 
345
- for (const entry of this.activeToasts.values()) clearTimeout(entry.timeoutId);
346
- this.activeToasts.clear();
2173
+ if (!manifest || !currentAppUrl || manifest.appUrl === currentAppUrl) return;
347
2174
 
348
- for (const timer of this.exitTimers) clearTimeout(timer);
349
- this.exitTimers.clear();
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
- for (const node of this.domNodes) node.remove();
352
- this.domNodes.clear();
2182
+ private updateManifestHud(manifest?: CapturedAppManifest): void {
2183
+ if (!this.options.ui || !hasDocument() || !document.body) return;
353
2184
 
354
- if (this.stackEl) {
355
- this.stackEl.remove();
356
- this.stackEl = null;
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
- if (this.styleEl) {
360
- this.styleEl.remove();
361
- this.styleEl = null;
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.memoryCounters.clear();
365
- this.credentials.length = 0;
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 handleAppEvent(event: Record<string, unknown>): Promise<unknown> {
369
- const type = typeof event?.type === 'string' ? event.type : '';
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
- switch (type) {
372
- case 'send-credential': {
373
- const name = readAchievementName(event);
374
- const templateAlias =
375
- typeof event.templateAlias === 'string' ? event.templateAlias : undefined;
376
- const boostUri = `lc:mock:boost:${String(event.templateAlias ?? 'template')}`;
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
- if (event.preventDuplicateClaim) {
379
- const existing = this.selfCredentials().find(c =>
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
- const record = this.addCredential({
399
- name,
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
- case 'check-credential': {
416
- const held = this.selfCredentials().find(c => this.matchesTemplate(c, event));
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
- case 'check-issuance-status': {
436
- const recipient = typeof event.recipient === 'string' ? event.recipient : '';
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
- case 'get-template-recipients': {
460
- const matched = this.credentials.filter(c => this.matchesTemplate(c, event));
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
- case 'send-notification': {
488
- const title = typeof event.title === 'string' ? event.title : '';
489
- const body = typeof event.body === 'string' ? event.body : '';
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
- case 'increment-counter': {
501
- const key = String(event.key ?? '');
502
- const amount = typeof event.amount === 'number' ? event.amount : 0;
503
- const previous = this.readCounter(key)?.value ?? 0;
504
- const next = previous + amount;
505
- this.writeCounter(key, next);
506
- this.toast({
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
- case 'get-counter': {
514
- const key = String(event.key ?? '');
515
- const stored = this.readCounter(key);
516
- const value = stored?.value ?? 0;
517
- this.toast({
518
- icon: '🔢',
519
- segments: ['Counter ', { b: key }, ' is ', { b: String(value) }, '.'],
520
- });
521
- return Promise.resolve({
522
- key,
523
- value,
524
- updatedAt: stored?.updatedAt ?? null,
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
- case 'get-counters': {
529
- const requested = Array.isArray(event.keys)
530
- ? (event.keys as unknown[]).map(String)
531
- : this.allCounterKeys();
532
- const counters = requested.map(key => {
533
- const stored = this.readCounter(key);
534
- return { key, value: stored?.value ?? 0, updatedAt: stored?.updatedAt ?? null };
535
- });
536
- this.toast({
537
- icon: '🔢',
538
- segments: ['Read ', { b: String(counters.length) }, ' counter(s).'],
539
- });
540
- return Promise.resolve({ counters });
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
- case 'send-ai-session-credential': {
544
- const sessionTitle =
545
- typeof event.sessionTitle === 'string' && event.sessionTitle
546
- ? event.sessionTitle
547
- : 'AI session';
548
- const sessionBoostUri = this.nextUri('lc:mock:session-boost');
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
- // Mirrors the real host's topic hierarchy: the first session
563
- // creates the app's AI Topic (returning its credential URI);
564
- // later sessions reuse it and report isNewTopic: false.
565
- const isNewTopic = this.aiTopic === null;
566
- if (!this.aiTopic) {
567
- this.aiTopic = {
568
- topicUri: this.nextUri('lc:mock:topic'),
569
- topicCredentialUri: this.nextUri('lc:mock:topic-credential'),
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
- return Promise.resolve({
574
- topicUri: this.aiTopic.topicUri,
575
- ...(isNewTopic ? { topicCredentialUri: this.aiTopic.topicCredentialUri } : {}),
576
- sessionCredentialUri: record.credentialUri,
577
- sessionBoostUri,
578
- isNewTopic,
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
- default:
583
- this.toast({
584
- icon: '✨',
585
- segments: ['In LearnCard, this would run ', { b: type }, '.'],
586
- });
587
- return Promise.resolve({});
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: ['In LearnCard, the user would receive ', { b: credentialName }, ' here.'],
2600
+ segments: [
2601
+ 'In LearnCard, the learner would receive ',
2602
+ { b: credentialName },
2603
+ `.${versionSuffix}`,
2604
+ ],
734
2605
  });
735
2606
  }
736
2607
 
737
- private showConsentBanner(redirectIgnored = false): void {
738
- this.toast({
739
- icon: '🔓',
740
- tone: 'positive',
741
- segments: redirectIgnored
742
- ? ['Consent auto-granted. Redirect ignored in mock.']
743
- : ['The user would review and grant consent. Auto-granted in mock.'],
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 key = `${tone}|${spec.icon}|${text}`;
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
- clearTimeout(existing.timeoutId);
779
- existing.timeoutId = setTimeout(() => this.dismissToast(key), ttl);
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 = document.createElement('div');
788
- toast.className = `lc-mock-toast lc-mock-toast--${tone}`;
789
-
790
- const badge = document.createElement('div');
791
- badge.className = 'lc-mock-badge';
792
- const name = document.createElement('span');
793
- name.className = 'lc-mock-badge-name';
794
- name.textContent = `${spec.icon} LearnCard`;
795
- const pill = document.createElement('span');
796
- pill.className = 'lc-mock-pill';
797
- pill.textContent = 'MOCK';
798
- badge.append(name, pill);
799
-
800
- const body = document.createElement('div');
801
- body.className = 'lc-mock-body';
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
- const strong = document.createElement('strong');
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
- const countEl = document.createElement('span');
813
- countEl.className = 'lc-mock-count';
814
- countEl.style.display = 'none';
815
- body.appendChild(countEl);
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
- const timeoutId = setTimeout(() => this.dismissToast(key), ttl);
822
- this.activeToasts.set(key, { node: toast, timeoutId, count: 1, countEl });
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 = document.createElement('div');
847
- stack.className = 'lc-mock-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
  }