@pageloop/client 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +94 -0
  2. package/README.md +97 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/.tsbuildinfo.preact +1 -0
  5. package/dist/.tsbuildinfo.react +1 -0
  6. package/dist/.tsbuildinfo.solid +1 -0
  7. package/dist/ApiClient.d.ts +121 -0
  8. package/dist/ApiClient.d.ts.map +1 -0
  9. package/dist/ApiClient.js +512 -0
  10. package/dist/ApiClient.js.map +1 -0
  11. package/dist/CommentEngine.d.ts +111 -0
  12. package/dist/CommentEngine.d.ts.map +1 -0
  13. package/dist/CommentEngine.js +277 -0
  14. package/dist/CommentEngine.js.map +1 -0
  15. package/dist/EventBus.d.ts +122 -0
  16. package/dist/EventBus.d.ts.map +1 -0
  17. package/dist/EventBus.js +34 -0
  18. package/dist/EventBus.js.map +1 -0
  19. package/dist/IdbCache.d.ts +22 -0
  20. package/dist/IdbCache.d.ts.map +1 -0
  21. package/dist/IdbCache.js +79 -0
  22. package/dist/IdbCache.js.map +1 -0
  23. package/dist/PageLoop.d.ts +424 -0
  24. package/dist/PageLoop.d.ts.map +1 -0
  25. package/dist/PageLoop.js +1092 -0
  26. package/dist/PageLoop.js.map +1 -0
  27. package/dist/PageTracker.d.ts +32 -0
  28. package/dist/PageTracker.d.ts.map +1 -0
  29. package/dist/PageTracker.js +105 -0
  30. package/dist/PageTracker.js.map +1 -0
  31. package/dist/UIRenderer.d.ts +218 -0
  32. package/dist/UIRenderer.d.ts.map +1 -0
  33. package/dist/UIRenderer.js +2 -0
  34. package/dist/UIRenderer.js.map +1 -0
  35. package/dist/auth/resolveInitialToken.d.ts +49 -0
  36. package/dist/auth/resolveInitialToken.d.ts.map +1 -0
  37. package/dist/auth/resolveInitialToken.js +97 -0
  38. package/dist/auth/resolveInitialToken.js.map +1 -0
  39. package/dist/errorCode.d.ts +12 -0
  40. package/dist/errorCode.d.ts.map +1 -0
  41. package/dist/errorCode.js +21 -0
  42. package/dist/errorCode.js.map +1 -0
  43. package/dist/index.d.ts +20 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +18 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/notifications/BrowserNotifications.d.ts +68 -0
  48. package/dist/notifications/BrowserNotifications.d.ts.map +1 -0
  49. package/dist/notifications/BrowserNotifications.js +147 -0
  50. package/dist/notifications/BrowserNotifications.js.map +1 -0
  51. package/dist/preact/index.d.ts +37 -0
  52. package/dist/preact/index.d.ts.map +1 -0
  53. package/dist/preact/index.js +150 -0
  54. package/dist/preact/index.js.map +1 -0
  55. package/dist/preact/style.css +12 -0
  56. package/dist/react/index.d.ts +52 -0
  57. package/dist/react/index.d.ts.map +1 -0
  58. package/dist/react/index.js +165 -0
  59. package/dist/react/index.js.map +1 -0
  60. package/dist/react/style.css +12 -0
  61. package/dist/safeStorage.d.ts +26 -0
  62. package/dist/safeStorage.d.ts.map +1 -0
  63. package/dist/safeStorage.js +78 -0
  64. package/dist/safeStorage.js.map +1 -0
  65. package/dist/solid/index.d.ts +40 -0
  66. package/dist/solid/index.d.ts.map +1 -0
  67. package/dist/solid/index.jsx +134 -0
  68. package/dist/solid/index.jsx.map +1 -0
  69. package/dist/solid/style.css +12 -0
  70. package/package.json +85 -0
@@ -0,0 +1,1092 @@
1
+ import { ERROR_CODES } from '@pageloop/shared';
2
+ import { errorCode } from './errorCode.js';
3
+ import { ApiClient } from './ApiClient.js';
4
+ import { CommentEngine } from './CommentEngine.js';
5
+ import { PageTracker } from './PageTracker.js';
6
+ import { EventBus } from './EventBus.js';
7
+ import { IdbCache } from './IdbCache.js';
8
+ import { resolveInitialToken, SESSION_TOKEN_KEY } from './auth/resolveInitialToken.js';
9
+ import { BrowserNotifications } from './notifications/BrowserNotifications.js';
10
+ import { safeStorage } from './safeStorage.js';
11
+ /**
12
+ * localStorage key for the cached `ProjectSettings` blob. Keyed per
13
+ * project so multiple PageLoop widgets on the same origin don't
14
+ * stomp each other. The cache is read synchronously on init — that's
15
+ * the whole point: we paint the toolbar + sidebar in their final
16
+ * position before bootstrap returns.
17
+ */
18
+ const SETTINGS_CACHE_PREFIX = 'pl_config:';
19
+ const PROJECT_CACHE_PREFIX = 'pl_project:';
20
+ const USER_SESSION_CACHE_PREFIX = 'pl_widget_user:';
21
+ /** Min gap between focus-driven session re-resolves (see `onVisible`). */
22
+ const SESSION_REFRESH_THROTTLE_MS = 60_000;
23
+ function readCachedSettings(projectId) {
24
+ // safeStorage swallows SecurityError / private-mode throws + falls back
25
+ // to an in-memory map; the try/catch here is purely for the JSON.parse.
26
+ const raw = safeStorage.getItem(SETTINGS_CACHE_PREFIX + projectId);
27
+ if (!raw)
28
+ return null;
29
+ try {
30
+ return JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function writeCachedSettings(projectId, settings) {
37
+ safeStorage.setItem(SETTINGS_CACHE_PREFIX + projectId, JSON.stringify(settings));
38
+ }
39
+ /**
40
+ * Cache the full Project object so the renderer can mount on first
41
+ * paint without waiting for the bootstrap round-trip. Validated on
42
+ * read — a partial / malformed cache returns null so we fall back to
43
+ * the slow-path mount-after-bootstrap.
44
+ *
45
+ * Separate from the settings cache because settings get reconciled
46
+ * key-by-key (and emit `config:changed` for hot reload), whereas the
47
+ * Project object is a static identity blob — name, slug, ids — that
48
+ * the renderer reads through the `project()` getter.
49
+ */
50
+ function readCachedProject(projectId) {
51
+ const raw = safeStorage.getItem(PROJECT_CACHE_PREFIX + projectId);
52
+ if (!raw)
53
+ return null;
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ // Defensive: a malformed cache (truncated, schema-drift) would
57
+ // crash the renderer's getters on optimistic paint. Require the
58
+ // minimum keys the mount path reads before trusting it.
59
+ if (!parsed.id || !parsed.slug)
60
+ return null;
61
+ return parsed;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function writeCachedProject(projectId, project) {
68
+ safeStorage.setItem(PROJECT_CACHE_PREFIX + projectId, JSON.stringify(project));
69
+ }
70
+ function readCachedUserSession(projectId) {
71
+ const raw = safeStorage.getItem(USER_SESSION_CACHE_PREFIX + projectId);
72
+ if (!raw)
73
+ return null;
74
+ try {
75
+ const parsed = JSON.parse(raw);
76
+ // Defensive: require the role to be set; a malformed blob (truncated
77
+ // JSON, schema drift) returns null so the constructor falls back to
78
+ // the anonymous default rather than rendering with garbage.
79
+ if (!parsed || typeof parsed.role !== 'string')
80
+ return null;
81
+ return parsed;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ function writeCachedUserSession(projectId, value) {
88
+ safeStorage.setItem(USER_SESSION_CACHE_PREFIX + projectId, JSON.stringify(value));
89
+ }
90
+ function clearCachedUserSession(projectId) {
91
+ safeStorage.removeItem(USER_SESSION_CACHE_PREFIX + projectId);
92
+ }
93
+ /**
94
+ * Public entry point. Composes ApiClient + PageTracker + CommentEngine + the
95
+ * provided UIRenderer, and runs the bootstrap → mount → subscribe lifecycle.
96
+ *
97
+ * Each framework wrapper (vanilla, react, preact, solid) instantiates this
98
+ * class internally and wires its own renderer into `deps.renderer`.
99
+ */
100
+ export class PageLoop {
101
+ config;
102
+ bus;
103
+ api;
104
+ tracker;
105
+ comments;
106
+ cache;
107
+ /** OS-level notification surface — listens for server-pushed
108
+ * `notification.show` events and surfaces them via the standard
109
+ * Web Notification API when the user has granted permission.
110
+ * Permission is requested explicitly from the settings UI; this
111
+ * manager never prompts silently. */
112
+ notifications;
113
+ renderer;
114
+ project = null;
115
+ currentPage = null;
116
+ /** Currently authenticated user, populated from bootstrap. */
117
+ currentUser = null;
118
+ /** Current user's role in the project; bootstrap doesn't return this directly,
119
+ * so we infer from `user.platformRole === 'superadmin'` || comment-create
120
+ * succeeded. For v1 we ask the server explicitly only when needed; the
121
+ * conservative default for new users is 'commenter' (most common case). */
122
+ currentRole = 'anonymous';
123
+ /** Signed-in user's preferred bubble colour (`#rrggbb`), resolved by the
124
+ * server at bootstrap from their account `appearance` settings. Null when
125
+ * anonymous or unset. Surfaced to the renderer via the `bubbleColor()`
126
+ * dep + kept fresh when the user recolours from the widget settings
127
+ * panel (the `bubbles:recolor` bus subscription below). */
128
+ currentBubbleColor = null;
129
+ /** Wall-clock of the last successful bootstrap — throttles the
130
+ * focus-driven session auto-refresh (`onVisible`). */
131
+ lastBootstrapAt = 0;
132
+ /** Guard so the visibilitychange listener is wired exactly once. */
133
+ visibilityWired = false;
134
+ /** Phase 2 subscriptions — wired by `runPhase2()`, dropped by
135
+ * `tearDownPhase2()`. The always-on `projects.updated` listener
136
+ * lives separately on `offProjectUpdated` so it survives transitions. */
137
+ offHandlers = [];
138
+ /** Always-on subscription to `projects.updated`. Survives phase 2
139
+ * teardown so a disabled-then-enabled cycle can wake back up. */
140
+ offProjectUpdated = null;
141
+ /** Always-on subscription to `bubbles:recolor` (widget settings panel).
142
+ * Keeps `currentBubbleColor` in sync so the renderer's `bubbleColor()`
143
+ * dep — and thus the colour picker on reopen — reflects the live value
144
+ * rather than the stale bootstrap one. */
145
+ offBubbleRecolor = null;
146
+ /**
147
+ * Tracks whether the user-facing widget (Phase 2) is currently
148
+ * mounted. The master kill-switch `behavior.enabled` (default true)
149
+ * gates this — when false, Phase 2 stays down: no renderer, no
150
+ * WebSocket, no event subscriptions, no DOM listeners on the host
151
+ * page. Phase 1 (bootstrap + cache reconcile) still runs so a
152
+ * subsequent flip-on via `config:changed` can transition us up
153
+ * without a page reload.
154
+ */
155
+ phase2Active = false;
156
+ /**
157
+ * Set by `dismiss()` — the user hid all PageLoop UI (e.g. for a
158
+ * screenshot). Keeps `reEvaluatePhase2()` from silently re-mounting on
159
+ * the next settings reconcile / tab-refocus. Not persisted, so a real
160
+ * page reload starts fresh with the widget visible again.
161
+ */
162
+ dismissed = false;
163
+ /** Captured from the renderer's `mount` return so deep-link
164
+ * handlers (`?pl-comment=<id>`) can call `focusComment` after the
165
+ * sidebar is up. Stays null when the renderer hasn't mounted yet
166
+ * (e.g. during a stop/restart). */
167
+ sidebarHandle = null;
168
+ /** Same idea for the toolbar handle — needed so refreshBubbles()
169
+ * can push the live comment count to the docked toolbar / mini
170
+ * widget bubble. Was previously discarded from the mount return,
171
+ * which is why the mini bubble's centered count stuck at 0. */
172
+ toolbarHandle = null;
173
+ /** Latest set of comment ids whose anchor couldn't resolve in the
174
+ * current DOM. Updated by the `bubbles:dangling` event the bubble
175
+ * layer emits at the end of every repaint. Threaded into
176
+ * SidebarState on the next refreshSidebar() so the comments tab
177
+ * can mark those rows as "no anchor". */
178
+ danglingCommentIds = new Set();
179
+ /**
180
+ * Server-provided settings, hydrated synchronously from localStorage on
181
+ * boot and revalidated by bootstrap. Snippet config still wins per-key
182
+ * via `effectiveUi()` — this is just the fallback set.
183
+ */
184
+ serverSettings;
185
+ constructor(config, deps) {
186
+ this.config = config;
187
+ this.bus = new EventBus();
188
+ this.notifications = new BrowserNotifications({
189
+ bus: this.bus,
190
+ debug: (...args) => this.debug(...args),
191
+ });
192
+ this.notifications.start();
193
+ // Hydrate settings + project synchronously from localStorage so
194
+ // the toolbar can mount in its final position on the first
195
+ // frame, before any network round-trip. Revalidation may emit
196
+ // `config:changed` afterwards if the server sent something
197
+ // different; the project metadata changes silently (renderer
198
+ // reads via the `project()` getter on each event).
199
+ this.serverSettings = readCachedSettings(config.projectId) ?? {};
200
+ this.project = readCachedProject(config.projectId);
201
+ const cachedBehavior = this.serverSettings.behavior ?? {};
202
+ // Pick up the post-OAuth bearer token from the URL fragment +
203
+ // persist it to localStorage, OR re-read a saved token from a
204
+ // prior session. Falls back to whatever the embedder passed in.
205
+ // See resolveInitialToken() for the priority order + URL-cleanup
206
+ // semantics.
207
+ const initialToken = resolveInitialToken(config.token);
208
+ // Hydrate the cached user identity + role so the toolbar paints
209
+ // the signed-in pill on first frame, before bootstrap returns.
210
+ // ONLY trust the cache when a token also exists — without a
211
+ // token we have nothing to authenticate the cached identity
212
+ // against, so we have to start anonymous and let bootstrap
213
+ // figure it out. Sign-out also clears this cache below
214
+ // (`bootstrap returns anonymous` and the in-cleanup paths), so
215
+ // a deliberate sign-out can't be undone by a stale cache hit.
216
+ if (initialToken) {
217
+ const cachedSession = readCachedUserSession(config.projectId);
218
+ if (cachedSession) {
219
+ this.currentUser = cachedSession.user;
220
+ this.currentRole = cachedSession.role;
221
+ }
222
+ }
223
+ this.api = new ApiClient({
224
+ endpoint: config.endpoint,
225
+ projectId: config.projectId,
226
+ token: initialToken,
227
+ transport: config.transport ?? cachedBehavior.transport,
228
+ }, this.bus);
229
+ this.tracker = new PageTracker(this.bus);
230
+ this.comments = new CommentEngine(this.api, this.bus);
231
+ this.cache = new IdbCache(config.projectId);
232
+ this.renderer = deps.renderer;
233
+ }
234
+ /**
235
+ * Effective UI config. Server wins per-key — admin is the source of
236
+ * truth for these knobs. Snippet values act as initial fallbacks for
237
+ * the synchronous first paint (before bootstrap returns); once
238
+ * reconciled, the server values overwrite. Identity / security keys
239
+ * (endpoint, projectId, token, debug) are snippet-only and live on
240
+ * `this.config` directly — they're never reachable from here.
241
+ */
242
+ effectiveUi() {
243
+ const snippet = this.config.ui ?? {};
244
+ const server = this.serverSettings.ui ?? {};
245
+ // sidebarPosition rolls up the legacy sidebarSide on either side
246
+ // when the new field is missing. Renderer reads sidebarPosition
247
+ // exclusively; sidebarSide is preserved on the merged shape so
248
+ // older renderers in flight don't break.
249
+ const sidebarPosition = server.sidebarPosition ??
250
+ snippet.sidebarPosition ??
251
+ server.sidebarSide ??
252
+ snippet.sidebarSide;
253
+ return {
254
+ toolbarPosition: server.toolbarPosition ?? snippet.toolbarPosition,
255
+ widgetMode: server.widgetMode ?? snippet.widgetMode,
256
+ widgetCorner: server.widgetCorner ?? snippet.widgetCorner,
257
+ sidebarPosition,
258
+ sidebarSide: server.sidebarSide ?? snippet.sidebarSide,
259
+ sidebarDisplayMode: server.sidebarDisplayMode ?? snippet.sidebarDisplayMode,
260
+ theme: server.theme ?? snippet.theme,
261
+ allowInlineCommenting: server.allowInlineCommenting ?? snippet.allowInlineCommenting,
262
+ tooltipDelayMs: server.tooltipDelayMs ?? snippet.tooltipDelayMs,
263
+ bubbleFollowAnimations: server.bubbleFollowAnimations ?? snippet.bubbleFollowAnimations,
264
+ bubbleZIndex: server.bubbleZIndex ?? snippet.bubbleZIndex,
265
+ signInMode: server.signInMode ?? snippet.signInMode,
266
+ };
267
+ }
268
+ /** Same precedence rule for behaviour knobs — server wins. */
269
+ effectiveBehavior() {
270
+ const server = this.serverSettings.behavior ?? {};
271
+ return {
272
+ enabled: server.enabled,
273
+ autoDiscover: server.autoDiscover ?? this.config.autoDiscover,
274
+ transport: server.transport ?? this.config.transport,
275
+ };
276
+ }
277
+ /**
278
+ * Master kill-switch reader. Defaults to true when the admin has
279
+ * never set the value (backward compat — existing projects keep
280
+ * working). Phase-2 mount is gated on this; on a live flip via
281
+ * `config:changed` the same getter drives the up/down transition.
282
+ */
283
+ isWidgetEnabled() {
284
+ return this.effectiveBehavior().enabled !== false;
285
+ }
286
+ /**
287
+ * Reconcile server settings into the cache + emit `config:changed` if
288
+ * any visible knob actually moved. Called once after bootstrap; cheap
289
+ * enough to JSON.stringify-compare since the blob is tiny.
290
+ */
291
+ reconcileSettings(next) {
292
+ const incoming = next ?? {};
293
+ const before = JSON.stringify(this.serverSettings);
294
+ const after = JSON.stringify(incoming);
295
+ if (before === after)
296
+ return;
297
+ this.serverSettings = incoming;
298
+ writeCachedSettings(this.config.projectId, incoming);
299
+ this.bus.emit('config:changed', { settings: incoming, ui: this.effectiveUi() });
300
+ }
301
+ /**
302
+ * Mount the renderer with the standard dep-getter set. Factored out
303
+ * of `start()` so both the optimistic-paint path (cached project,
304
+ * before bootstrap) and the slow path (no cache, after bootstrap)
305
+ * share one mount call site. Captures the sidebar handle + replays
306
+ * the connection state so the toolbar's "online" badge picks up
307
+ * any already-completed `api.connect()` resolution.
308
+ */
309
+ async mountRenderer() {
310
+ const mounted = await this.renderer.mount({
311
+ bus: this.bus,
312
+ api: this.api,
313
+ comments: this.comments,
314
+ notifications: this.notifications,
315
+ page: () => this.currentPage,
316
+ project: () => this.project,
317
+ user: () => this.currentUser,
318
+ role: () => this.currentRole,
319
+ bubbleColor: () => this.currentBubbleColor,
320
+ applyToken: (token) => this.applyToken(token),
321
+ dismiss: () => {
322
+ void this.dismiss();
323
+ },
324
+ ui: () => this.effectiveUi(),
325
+ registerCurrentPage: () => this.registerCurrentPage().catch((err) => {
326
+ console.warn('[PageLoop] registerCurrentPage failed:', err);
327
+ return null;
328
+ }),
329
+ pageKey: () => this.tracker.pageKey(),
330
+ debug: (...args) => this.debug(...args),
331
+ release: () => ({
332
+ version: this.config.version,
333
+ notesUrl: this.config.releaseNotesUrl,
334
+ }),
335
+ });
336
+ this.sidebarHandle = mounted.sidebar;
337
+ this.toolbarHandle = mounted.toolbar;
338
+ // Re-emit connection state — the renderer just registered its
339
+ // listener, and `api.connect()` may have already resolved before
340
+ // this mount call.
341
+ this.api.replayConnectionState();
342
+ }
343
+ async start() {
344
+ this.debug('start: config', {
345
+ endpoint: this.config.endpoint,
346
+ projectId: this.config.projectId,
347
+ hasToken: !!this.config.token,
348
+ });
349
+ // Optimistic paint from IDB while the network call is in flight. The
350
+ // bootstrap call below reconciles afterward and emits events that update
351
+ // both UI and cache. This means a cold reload of a known page renders
352
+ // comments immediately without waiting for a round-trip.
353
+ const cachedKey = this.tracker.pageKey();
354
+ this.debug('start: pageKey', cachedKey);
355
+ const cachedPage = await this.cache.getPage(cachedKey);
356
+ if (cachedPage) {
357
+ this.debug('start: idb cache hit, optimistic paint', { pageId: cachedPage.id });
358
+ this.currentPage = cachedPage;
359
+ const cachedComments = await this.cache.getComments(cachedPage.id);
360
+ if (cachedComments)
361
+ this.comments.prime(cachedPage.id, cachedComments);
362
+ }
363
+ // Phase-2 gate: master kill-switch. When the admin has flipped
364
+ // `behavior.enabled` to false the widget renders nothing and
365
+ // opens no connections; we still let bootstrap run below so a
366
+ // future flip-on (next page load) sees the latest server value.
367
+ const cachedEnabled = this.isWidgetEnabled();
368
+ // If we have a cached Project (constructor hydrated `this.project`
369
+ // from localStorage) AND the cache says the widget is enabled,
370
+ // mount the renderer + open the WS NOW — before bootstrap —
371
+ // so the toolbar appears on the first frame instead of waiting
372
+ // for the network round-trip. The deps' getters return live
373
+ // values, so updates from bootstrap flow through naturally
374
+ // (`config:changed` + `auth:changed` events trigger re-render).
375
+ //
376
+ // First-visit users (no cached project) OR projects whose
377
+ // cached settings say enabled=false take the slow path below —
378
+ // toolbar stays hidden until bootstrap returns; if bootstrap
379
+ // confirms the widget is enabled the post-reconcile re-eval
380
+ // flips on Phase 2 then. If bootstrap says disabled, we stay
381
+ // inert.
382
+ if (cachedEnabled && this.project) {
383
+ this.debug('start: optimistic Phase 2 from cached project + enabled', {
384
+ slug: this.project.slug,
385
+ });
386
+ try {
387
+ await this.runPhase2();
388
+ }
389
+ catch (err) {
390
+ this.debug('start: optimistic Phase 2 failed, will retry post-bootstrap', err);
391
+ }
392
+ }
393
+ else if (!cachedEnabled) {
394
+ this.debug('start: widget disabled in cache, deferring Phase 2');
395
+ }
396
+ // Bootstrap: project + (maybe) page + comments + me + prefs in one round-trip.
397
+ // Resilience principle: PageLoop must never block the host page. A
398
+ // stale/foreign-secret token in localStorage (common when a user has
399
+ // hit multiple PageLoop servers — local + prod — from the same
400
+ // origin) used to leave the widget unmounted. Now we recover:
401
+ // 1. If bootstrap throws UNAUTHORIZED *and* we sent a token, clear
402
+ // it and retry anonymous. Project publicRead determines whether
403
+ // the retry succeeds.
404
+ // 2. If the retry (or any other failure) still throws, log + bail
405
+ // gracefully — no UI mounted, no thrown promise, host page is
406
+ // untouched.
407
+ let initial = null;
408
+ let lastError = null;
409
+ try {
410
+ initial = await this.api.call('bootstrap', {
411
+ projectId: this.config.projectId,
412
+ pageKey: cachedKey,
413
+ });
414
+ }
415
+ catch (err) {
416
+ lastError = err;
417
+ const isUnauthorized = errorCode(err) === ERROR_CODES.UNAUTHORIZED;
418
+ if (isUnauthorized && this.api.getToken()) {
419
+ // The widget can't authenticate to this project — could be a
420
+ // stale dev token, a real session that lacks membership, or
421
+ // a token minted under a different `PAGELOOP_JWT_SECRET`.
422
+ //
423
+ // Critically: DO NOT clear localStorage here. The widget and
424
+ // the host page (admin SPA, an embedder app) share the same
425
+ // `pl_token` key by design — wiping it would sign the user
426
+ // out of the admin or any other PageLoop surface that's open
427
+ // in the same browser. Drop the widget's in-memory token so
428
+ // THIS bootstrap retry runs anonymously, but leave the
429
+ // persisted token alone. If the token is genuinely stale, the
430
+ // admin's own session-resolution path (`me`) will detect that
431
+ // and clear it cleanly through the proper signed-out flow.
432
+ this.debug('start: bootstrap UNAUTHORIZED — dropping in-memory token, retrying anon');
433
+ this.api.setToken(null);
434
+ // Cached user identity is now stale — the server rejected
435
+ // the token that authenticated it. Clear so the next cold
436
+ // load doesn't paint the old user pill while bootstrap
437
+ // re-resolves as anonymous.
438
+ clearCachedUserSession(this.config.projectId);
439
+ this.currentUser = null;
440
+ this.currentRole = 'anonymous';
441
+ try {
442
+ initial = await this.api.call('bootstrap', {
443
+ projectId: this.config.projectId,
444
+ pageKey: cachedKey,
445
+ });
446
+ lastError = null;
447
+ }
448
+ catch (err2) {
449
+ lastError = err2;
450
+ this.debug('start: anonymous bootstrap also failed', err2);
451
+ }
452
+ }
453
+ else {
454
+ this.debug('start: bootstrap failed', err);
455
+ }
456
+ }
457
+ if (!initial) {
458
+ // Final fallback — the widget can't mount, but the host page is
459
+ // untouched. Surface enough detail that an embedder can debug
460
+ // without enabling debug=true.
461
+ const errMsg = lastError instanceof Error ? lastError.message : String(lastError);
462
+ const code = errorCode(lastError);
463
+ const hint = errMsg.includes('requires a project scope')
464
+ ? `Project "${this.config.projectId}" not found at ${this.config.endpoint}. Check projectId or confirm the server hosting this project is the one running on that endpoint.`
465
+ : code === ERROR_CODES.UNAUTHORIZED
466
+ ? `Server rejected the request (UNAUTHORIZED) and anonymous fallback also failed. The project likely requires sign-in (publicRead is off).`
467
+ : code === ERROR_CODES.FORBIDDEN
468
+ ? `Anonymous access denied. Either sign in, or enable publicRead in the project settings.`
469
+ : `Could not reach ${this.config.endpoint}: ${errMsg}`;
470
+ console.warn(`[PageLoop] widget will not mount — ${hint}`);
471
+ return;
472
+ }
473
+ this.project = initial.project;
474
+ this.currentPage = initial.page;
475
+ this.currentUser = initial.user;
476
+ this.currentRole = initial.role;
477
+ this.currentBubbleColor = initial.bubbleColor ?? null;
478
+ // Persist the fresh Project blob so the NEXT cold load can mount
479
+ // optimistically. Done BEFORE reconcileSettings so a partial
480
+ // failure mid-reconcile still leaves us with cache consistency.
481
+ writeCachedProject(this.config.projectId, initial.project);
482
+ // Persist the user + role so the NEXT cold load can paint the
483
+ // signed-in toolbar pill on first frame instead of flashing
484
+ // "Sign in" until bootstrap returns. Clears the cache when the
485
+ // server resolved this token as anonymous (token expired,
486
+ // revoked, or DB-deleted user) so the cached identity can't
487
+ // resurrect a session that the server no longer honors.
488
+ if (initial.user) {
489
+ writeCachedUserSession(this.config.projectId, {
490
+ user: initial.user,
491
+ role: initial.role,
492
+ });
493
+ }
494
+ else {
495
+ clearCachedUserSession(this.config.projectId);
496
+ }
497
+ // Reconcile server-driven config + persist for the next cold load.
498
+ // Emits `config:changed` if anything visible moved.
499
+ this.reconcileSettings(initial.project.settings);
500
+ // Surface auth state to wrappers so React/Preact/Solid hooks can
501
+ // re-render without polling. Anonymous → role='anonymous', user=null.
502
+ this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
503
+ this.lastBootstrapAt = Date.now();
504
+ // Re-resolve the session when the user returns to the tab (throttled),
505
+ // so an org/role change made while they were away is picked up without
506
+ // a manual reload. Wired once, dropped in stop().
507
+ if (!this.visibilityWired && typeof document !== 'undefined') {
508
+ this.visibilityWired = true;
509
+ document.addEventListener('visibilitychange', this.onVisible);
510
+ }
511
+ this.debug('start: bootstrap', {
512
+ project: initial.project.slug,
513
+ page: initial.page?.id ?? null,
514
+ comments: initial.comments.length,
515
+ user: initial.user?.email ?? null,
516
+ role: initial.role,
517
+ });
518
+ if (initial.page) {
519
+ this.comments.prime(initial.page.id, initial.comments);
520
+ await this.cache.putPage(initial.page);
521
+ await this.cache.putComments(initial.page.id, initial.comments);
522
+ }
523
+ // Project metadata can change at runtime (rename, settings
524
+ // toggle, master kill-switch flip). Wire this UNCONDITIONALLY
525
+ // — even when Phase 2 is currently down — because this is the
526
+ // signal the disabled-at-boot widget uses to wake up when the
527
+ // admin flips it back on. Lives on its own slot so phase 2
528
+ // teardown doesn't drop it.
529
+ if (!this.offProjectUpdated) {
530
+ this.offProjectUpdated = this.bus.on('projects.updated', (event) => {
531
+ if (!this.project || event?.project?.id !== this.project.id)
532
+ return;
533
+ this.project = event.project;
534
+ this.reconcileSettings(event.project.settings);
535
+ this.bus.emit('project:updated', event.project);
536
+ void this.reEvaluatePhase2();
537
+ if (this.phase2Active)
538
+ this.refreshSidebar();
539
+ });
540
+ }
541
+ // Keep the cached bubble colour fresh when the user recolours from
542
+ // the widget settings panel (the renderer persists to the server +
543
+ // re-tints live; we just track the value so `bubbleColor()` stays
544
+ // current for a settings-panel reopen). Own slot, wired once.
545
+ if (!this.offBubbleRecolor) {
546
+ this.offBubbleRecolor = this.bus.on('bubbles:recolor', (event) => {
547
+ this.currentBubbleColor = (event?.color ?? null);
548
+ });
549
+ }
550
+ // Reconcile complete. Re-evaluate the Phase 2 gate against the
551
+ // freshly-merged server settings:
552
+ // - cached said disabled, server says enabled → mount now
553
+ // - cached said enabled (we already mounted optimistically),
554
+ // server says disabled → tear down
555
+ // - both agree → no-op
556
+ await this.reEvaluatePhase2();
557
+ }
558
+ /**
559
+ * Phase 2 — the user-facing widget. Mounts the renderer, opens the
560
+ * WebSocket, starts the page tracker, and subscribes the bus to
561
+ * comment/page events. Idempotent: subsequent calls while
562
+ * `phase2Active === true` are no-ops.
563
+ *
564
+ * Kept separate from `start()` so the master kill-switch
565
+ * (`behavior.enabled`) can gate this whole surface without
566
+ * threading branches through every step. Cold-boot enters here
567
+ * once when enabled; live admin flips re-enter via
568
+ * `reEvaluatePhase2()`.
569
+ */
570
+ async runPhase2() {
571
+ if (this.phase2Active)
572
+ return;
573
+ this.phase2Active = true;
574
+ this.debug('phase2: starting');
575
+ // WebSocket dial — scheduled via requestIdleCallback so it
576
+ // yields to host-page first-paint / interactivity work. End
577
+ // state flips to online via the existing `connection:open`
578
+ // event.
579
+ this.scheduleIdle(() => {
580
+ this.api.connect().catch((err) => {
581
+ this.debug('phase2: connect failed', err);
582
+ });
583
+ });
584
+ await this.mountRenderer();
585
+ // Auto-discover: if no tracked page exists for this URL and the
586
+ // config permits it, register the page so visitors can
587
+ // immediately comment.
588
+ if (!this.currentPage && (this.effectiveBehavior().autoDiscover ?? true)) {
589
+ this.debug('phase2: auto-discovering current page');
590
+ try {
591
+ await this.registerCurrentPage();
592
+ }
593
+ catch (err) {
594
+ this.debug('phase2: auto-discover failed', err);
595
+ // FORBIDDEN / UNAUTHORIZED is the EXPECTED outcome on a private
596
+ // project viewed anonymously — the widget stays mounted in its
597
+ // anonymous state (Sign-in still available), so stay quiet. Any
598
+ // other failure (network, 5xx, …) is unexpected: surface a toast
599
+ // so the visitor/operator knows the page didn't get tracked and
600
+ // comments may be unavailable. (Errors arrive as `CODE: message`.)
601
+ const message = err instanceof Error ? err.message : String(err);
602
+ const isAuthError = /^(FORBIDDEN|UNAUTHORIZED)\b/.test(message);
603
+ if (!isAuthError) {
604
+ this.notify("Couldn't set up PageLoop on this page — comments may be unavailable. Try reloading.", { kind: 'error', durationMs: 6000 });
605
+ }
606
+ }
607
+ }
608
+ this.refreshSidebar();
609
+ this.refreshBubbles();
610
+ // Deep-link: `?pl-comment=<commentId>` on the current URL means
611
+ // "open the page focused on that comment".
612
+ this.maybeFocusFromUrl();
613
+ // Start tracking SPA navigation; resolve a tracked page on every change.
614
+ this.tracker.start();
615
+ const offChange = this.bus.on('page:change', async ({ key }) => {
616
+ await this.resolvePage(key);
617
+ });
618
+ // Live updates: re-render bubbles + sidebar when comments change.
619
+ const offCreated = this.bus.on('comment:created', () => this.refreshAfterChange());
620
+ const offUpdated = this.bus.on('comment:updated', () => this.refreshAfterChange());
621
+ const offDeleted = this.bus.on('comment:deleted', () => this.refreshAfterChange());
622
+ const offBulkDeleted = this.bus.on('comment:bulkDeleted', () => this.refreshAfterChange());
623
+ // Bubble layer emits the dangling set at the end of every
624
+ // anchor-resolve pass. Cache it + refresh the sidebar so the
625
+ // "no anchor" indicator flips on/off without waiting for the
626
+ // next user action.
627
+ const offDangling = this.bus.on('bubbles:dangling', (event) => {
628
+ const ids = event?.ids instanceof Set ? event.ids : new Set();
629
+ const changed = ids.size !== this.danglingCommentIds.size ||
630
+ [...ids].some((id) => !this.danglingCommentIds.has(id));
631
+ this.danglingCommentIds = ids;
632
+ if (changed)
633
+ this.refreshSidebar();
634
+ });
635
+ this.offHandlers.push(offChange, offCreated, offUpdated, offDeleted, offBulkDeleted, offDangling);
636
+ }
637
+ /**
638
+ * Phase 2 teardown — symmetric inverse of `runPhase2()`. Unmounts
639
+ * the renderer, stops the tracker, disconnects the WS, drops every
640
+ * Phase 2 subscription. The always-on `projects.updated` listener
641
+ * lives on `offProjectUpdated` and is untouched here so it can
642
+ * wake us back up when the admin flips the kill-switch on.
643
+ */
644
+ async tearDownPhase2() {
645
+ if (!this.phase2Active)
646
+ return;
647
+ this.debug('phase2: tearing down');
648
+ this.phase2Active = false;
649
+ this.tracker.stop();
650
+ for (const off of this.offHandlers)
651
+ off();
652
+ this.offHandlers = [];
653
+ await this.renderer.unmount();
654
+ this.sidebarHandle = null;
655
+ this.toolbarHandle = null;
656
+ this.api.disconnect();
657
+ }
658
+ /**
659
+ * Remove ALL PageLoop UI from the page — toolbar/mini, sidebar, bubbles,
660
+ * tooltips, chat, toasts — and disconnect the socket, until the next page
661
+ * load. Nothing is persisted: a refresh re-initializes the widget
662
+ * normally. Intended for screenshots / temporarily clearing the page; the
663
+ * toolbar + mini both expose an "X" that calls this. Idempotent.
664
+ */
665
+ async dismiss() {
666
+ this.dismissed = true;
667
+ await this.tearDownPhase2();
668
+ }
669
+ /**
670
+ * Drive Phase 2 to whatever state the latest effective
671
+ * `behavior.enabled` value implies. Called after every
672
+ * reconcileSettings so a live admin flip transitions us up or
673
+ * down without a page reload.
674
+ */
675
+ async reEvaluatePhase2() {
676
+ // `dismissed` wins until the next reload — a settings reconcile or
677
+ // tab-refocus must not resurrect the UI the user explicitly cleared.
678
+ const want = this.isWidgetEnabled() && !this.dismissed;
679
+ if (want && !this.phase2Active) {
680
+ await this.runPhase2();
681
+ }
682
+ else if (!want && this.phase2Active) {
683
+ await this.tearDownPhase2();
684
+ }
685
+ }
686
+ async stop() {
687
+ this.notifications.stop();
688
+ if (this.offProjectUpdated) {
689
+ this.offProjectUpdated();
690
+ this.offProjectUpdated = null;
691
+ }
692
+ if (this.offBubbleRecolor) {
693
+ this.offBubbleRecolor();
694
+ this.offBubbleRecolor = null;
695
+ }
696
+ if (this.visibilityWired && typeof document !== 'undefined') {
697
+ document.removeEventListener('visibilitychange', this.onVisible);
698
+ this.visibilityWired = false;
699
+ }
700
+ // `tearDownPhase2` drops Phase 2 subscriptions, stops the
701
+ // tracker, unmounts the renderer, and disconnects the WS. When
702
+ // Phase 2 was never started (widget disabled at boot) this
703
+ // no-ops cleanly.
704
+ if (this.phase2Active) {
705
+ await this.tearDownPhase2();
706
+ }
707
+ }
708
+ getCurrentPage() {
709
+ return this.currentPage;
710
+ }
711
+ /** Project this PageLoop instance is bound to (null until bootstrap). */
712
+ getProject() {
713
+ return this.project;
714
+ }
715
+ /** Authenticated user — null when anonymous. Updated after bootstrap. */
716
+ getCurrentUser() {
717
+ return this.currentUser;
718
+ }
719
+ /** Caller's role in the project — `'anonymous'` when not signed in. */
720
+ getCurrentRole() {
721
+ return this.currentRole;
722
+ }
723
+ /**
724
+ * Apply a freshly-obtained session token (sign-in modal, email/password
725
+ * login, etc.) WITHOUT reloading the host page: set the bearer, persist
726
+ * it, then re-resolve identity + comments via bootstrap and repaint.
727
+ * Emits `auth:changed` so the toolbar/mini swap to the signed-in state.
728
+ * Pass null to sign out. Because the server's JWT is identity-only
729
+ * (`{sub}`) and resolves org/role per-request from the DB, this also
730
+ * picks up any membership/role change the user has accrued since their
731
+ * last bootstrap — no special token-refresh dance required.
732
+ */
733
+ async applyToken(token) {
734
+ this.api.setToken(token);
735
+ try {
736
+ if (token)
737
+ localStorage.setItem(SESSION_TOKEN_KEY, token);
738
+ else
739
+ localStorage.removeItem(SESSION_TOKEN_KEY);
740
+ }
741
+ catch {
742
+ /* private mode — in-memory token still works for this tab */
743
+ }
744
+ await this.reBootstrap();
745
+ }
746
+ /**
747
+ * Re-run bootstrap under the CURRENT token and fold the result into live
748
+ * state (identity, role, project settings, comments) without remounting.
749
+ * Powers both `applyToken` (after a sign-in) and the focus-driven
750
+ * auto-refresh below. Because the JWT is identity-only and the server
751
+ * resolves org/role per-request, this is also how a user picks up a
752
+ * membership/role change (e.g. an admin adding them to an org) — no token
753
+ * reissue needed; the next bootstrap simply resolves the new permissions.
754
+ */
755
+ async reBootstrap() {
756
+ try {
757
+ const initial = await this.api.call('bootstrap', {
758
+ projectId: this.config.projectId,
759
+ pageKey: this.tracker.pageKey(),
760
+ });
761
+ this.lastBootstrapAt = Date.now();
762
+ this.project = initial.project;
763
+ this.currentPage = initial.page;
764
+ this.currentUser = initial.user;
765
+ this.currentRole = initial.role;
766
+ this.currentBubbleColor = initial.bubbleColor ?? null;
767
+ writeCachedProject(this.config.projectId, initial.project);
768
+ if (initial.user) {
769
+ writeCachedUserSession(this.config.projectId, {
770
+ user: initial.user,
771
+ role: initial.role,
772
+ });
773
+ }
774
+ else {
775
+ clearCachedUserSession(this.config.projectId);
776
+ }
777
+ this.reconcileSettings(initial.project.settings);
778
+ if (initial.page) {
779
+ this.comments.prime(initial.page.id, initial.comments);
780
+ await this.cache.putPage(initial.page);
781
+ await this.cache.putComments(initial.page.id, initial.comments);
782
+ }
783
+ this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
784
+ await this.reEvaluatePhase2();
785
+ if (this.phase2Active) {
786
+ this.refreshSidebar();
787
+ this.refreshBubbles();
788
+ }
789
+ }
790
+ catch (err) {
791
+ this.debug('reBootstrap failed', err);
792
+ // Still surface the current auth identity best-effort.
793
+ this.bus.emit('auth:changed', { user: this.currentUser, role: this.currentRole });
794
+ }
795
+ }
796
+ /**
797
+ * Tab-focus auto-refresh. When a signed-in user returns to the tab and
798
+ * it's been a while since the last bootstrap, re-resolve their session so
799
+ * any org/role change an admin made while they were away (e.g. adding a
800
+ * just-registered user to an organization) is picked up without a manual
801
+ * reload. Throttled so routine tab-switching doesn't spam the server.
802
+ */
803
+ onVisible = () => {
804
+ if (typeof document === 'undefined' || document.visibilityState !== 'visible')
805
+ return;
806
+ if (!this.currentUser)
807
+ return; // anonymous → nothing to re-resolve
808
+ if (Date.now() - this.lastBootstrapAt < SESSION_REFRESH_THROTTLE_MS)
809
+ return;
810
+ void this.reBootstrap();
811
+ };
812
+ /** Auto-register the current URL as a tracked page (for autoDiscover mode). */
813
+ async registerCurrentPage(title) {
814
+ if (!this.project)
815
+ throw new Error('PageLoop not started');
816
+ const key = this.tracker.pageKey();
817
+ const page = await this.api.call('pages.register', {
818
+ projectId: this.project.id,
819
+ sourceKind: 'live',
820
+ // For live URLs without an Endpoint, source_id == projectId — flat-mode default.
821
+ sourceId: this.project.id,
822
+ key,
823
+ title: title ?? document.title,
824
+ });
825
+ this.currentPage = page;
826
+ await this.comments.loadFromServer(page.id);
827
+ this.refreshSidebar();
828
+ this.refreshBubbles();
829
+ return page;
830
+ }
831
+ // --- internals ---
832
+ async resolvePage(key) {
833
+ if (!this.project)
834
+ return;
835
+ // Optimistic paint from cache, then reconcile.
836
+ const cached = await this.cache.getPage(key);
837
+ if (cached) {
838
+ this.currentPage = cached;
839
+ const cachedComments = await this.cache.getComments(cached.id);
840
+ if (cachedComments)
841
+ this.comments.prime(cached.id, cachedComments);
842
+ this.refreshSidebar();
843
+ this.refreshBubbles();
844
+ }
845
+ const page = await this.api.call('pages.byKey', { projectId: this.project.id, key });
846
+ // `displayVisibility === 'disabled'` is a hard refuse-to-mount. The
847
+ // widget treats the page as if it doesn't exist — no bubbles, no
848
+ // sidebar, no compose. Server still rejects `comments.create` for
849
+ // disabled pages; the client mirror keeps the offline-after-load
850
+ // state honest.
851
+ if (page && page.settings.displayVisibility === 'disabled') {
852
+ this.currentPage = null;
853
+ this.refreshSidebar();
854
+ this.refreshBubbles();
855
+ return;
856
+ }
857
+ this.currentPage = page;
858
+ if (page) {
859
+ const list = await this.comments.loadFromServer(page.id);
860
+ await this.cache.putPage(page);
861
+ await this.cache.putComments(page.id, list);
862
+ }
863
+ this.refreshSidebar();
864
+ this.refreshBubbles();
865
+ }
866
+ debug(...args) {
867
+ if (this.config.debug)
868
+ console.debug('[PageLoop]', ...args);
869
+ }
870
+ /**
871
+ * Schedule non-critical work for after the host page has settled.
872
+ * Uses `requestIdleCallback` where available (most modern browsers
873
+ * outside Safari pre-17), `setTimeout(0)` as a fallback. The
874
+ * timeout cap ensures the callback eventually runs even if the
875
+ * host stays busy — we don't want widget features to silently
876
+ * never activate.
877
+ */
878
+ scheduleIdle(fn) {
879
+ if (typeof window === 'undefined')
880
+ return;
881
+ const ric = window.requestIdleCallback;
882
+ if (typeof ric === 'function') {
883
+ ric(fn, { timeout: 3000 });
884
+ return;
885
+ }
886
+ setTimeout(fn, 50);
887
+ }
888
+ /**
889
+ * Consume `?pl-comment=<id>` (and optional `?pl-reply=<id>`) on
890
+ * the current URL: open the sidebar focused on that comment +
891
+ * animate the bubble. When `pl-reply` is also present, expand
892
+ * the comment's replies thread and scroll to the specific reply.
893
+ * Strips the params from the URL on success so a refresh doesn't
894
+ * re-focus.
895
+ *
896
+ * No-op when params are missing, the renderer isn't mounted, or
897
+ * the comment id isn't part of the currently-loaded page's set —
898
+ * the latter case means a stale deep-link; we silently drop it
899
+ * rather than confuse the user with an error toast.
900
+ */
901
+ maybeFocusFromUrl() {
902
+ if (typeof window === 'undefined')
903
+ return;
904
+ const params = new URLSearchParams(window.location.search);
905
+ const commentId = params.get('pl-comment');
906
+ const replyId = params.get('pl-reply');
907
+ if (!commentId)
908
+ return;
909
+ if (!this.sidebarHandle)
910
+ return;
911
+ // The bubble layer's `focusComment` / `focusReply` walk the
912
+ // cached anchor map, so we only need to ensure the comment is
913
+ // loaded. The optimistic IDB paint above usually has it; if
914
+ // the server hasn't returned yet, a subsequent comment-list
915
+ // repaint won't auto-focus — but the first attempt covers the
916
+ // common case (warm cache or fast network).
917
+ try {
918
+ if (replyId)
919
+ this.sidebarHandle.focusReply(commentId, replyId);
920
+ else
921
+ this.sidebarHandle.focusComment(commentId);
922
+ }
923
+ catch (err) {
924
+ this.debug('maybeFocusFromUrl: focus failed', err);
925
+ }
926
+ // Strip the params so a refresh stays where the user landed.
927
+ try {
928
+ params.delete('pl-comment');
929
+ params.delete('pl-reply');
930
+ const next = `${window.location.pathname}${params.toString() ? `?${params.toString()}` : ''}${window.location.hash}`;
931
+ window.history.replaceState({}, '', next);
932
+ }
933
+ catch {
934
+ /* replaceState unavailable (SSR, sandboxed iframe) — fine */
935
+ }
936
+ }
937
+ /**
938
+ * Programmatic entry point matching the URL deep-link semantics.
939
+ * Hosts that own their own routing can call this instead of (or
940
+ * after) appending `?pl-comment=<id>` to the URL — same end-state:
941
+ * sidebar opens, comment scrolls into view, bubble flashes. When
942
+ * `replyId` is set, also expands the replies thread + scrolls.
943
+ *
944
+ * No-op until `start()` resolves and the renderer is mounted.
945
+ */
946
+ focusComment(commentId, replyId) {
947
+ if (!this.sidebarHandle)
948
+ return;
949
+ try {
950
+ if (replyId)
951
+ this.sidebarHandle.focusReply(commentId, replyId);
952
+ else
953
+ this.sidebarHandle.focusComment(commentId);
954
+ }
955
+ catch (err) {
956
+ this.debug('focusComment: failed', err);
957
+ }
958
+ }
959
+ /**
960
+ * Build a same-page deep-link URL pointing at a specific comment
961
+ * (and optionally a reply within it). Hosts use this to construct
962
+ * notification links / "permalink" buttons / cross-page navigation
963
+ * targets without re-deriving the param convention each time.
964
+ *
965
+ * Returns a same-origin URL with the existing path/hash preserved
966
+ * and `pl-comment` / `pl-reply` query params appended.
967
+ */
968
+ static buildDeepLink(input) {
969
+ const baseStr = input.url ??
970
+ (typeof window === 'undefined'
971
+ ? (() => {
972
+ throw new Error('PageLoop.buildDeepLink: no `url` in non-browser context');
973
+ })()
974
+ : window.location.href);
975
+ // `URL` accepts relative inputs when given a base; fall back to
976
+ // the current origin so callers can pass `/foo/bar`.
977
+ const base = typeof window === 'undefined'
978
+ ? new URL(baseStr)
979
+ : new URL(baseStr, window.location.href);
980
+ base.searchParams.set('pl-comment', input.commentId);
981
+ if (input.replyId)
982
+ base.searchParams.set('pl-reply', input.replyId);
983
+ else
984
+ base.searchParams.delete('pl-reply');
985
+ return base.toString();
986
+ }
987
+ refreshAfterChange() {
988
+ this.refreshSidebar();
989
+ this.refreshBubbles();
990
+ // Sync the updated comment set back to cache so the next cold reload
991
+ // shows the right state without a round-trip.
992
+ if (this.currentPage) {
993
+ void this.cache.putComments(this.currentPage.id, this.comments.load(this.currentPage.id));
994
+ }
995
+ }
996
+ refreshSidebar() {
997
+ const list = this.currentPage ? this.comments.load(this.currentPage.id) : [];
998
+ this.renderer.renderSidebar({
999
+ page: this.currentPage,
1000
+ comments: list,
1001
+ danglingCommentIds: this.danglingCommentIds,
1002
+ });
1003
+ }
1004
+ refreshBubbles() {
1005
+ const list = this.currentPage ? this.comments.load(this.currentPage.id) : [];
1006
+ this.renderer.renderBubbles(list);
1007
+ // Push the live count to the toolbar handle. Comments are plain by
1008
+ // default now (no open/resolved issue status), so the badge counts
1009
+ // every comment EXCEPT completed tasks (a resolved task is done —
1010
+ // no "pressure"). The mini widget paints this in the center of the
1011
+ // corner bubble; the docked Toolbar is a no-op. The handle is null
1012
+ // until the renderer's first mount completes, so guard accordingly.
1013
+ const count = list.reduce((n, c) => (c.isTask && c.status === 'resolved' ? n : n + 1), 0);
1014
+ this.toolbarHandle?.setUnreadCount(count);
1015
+ }
1016
+ /**
1017
+ * Public hook for hosts that have just performed a layout change
1018
+ * the widget can't observe on its own (SPA route swap, accordion
1019
+ * open, a custom width-toggle on an admin shell, anything that
1020
+ * shifts anchored elements without firing scroll/resize/CSS-
1021
+ * transition events). Re-runs the bubble positioning routine
1022
+ * without rebuilding the bubble DOM. Safe to call before mount
1023
+ * — no-op until the renderer is up.
1024
+ *
1025
+ * Background work the widget runs anyway:
1026
+ * - A `ResizeObserver` on `<body>` + a 3s baseline poll catch
1027
+ * most cases without needing this call.
1028
+ * - The `bubbleFollowAnimations` project setting layers a rAF
1029
+ * loop on top during CSS transitions for sub-frame precision.
1030
+ * This API is for the cases neither of the above covers — when
1031
+ * you definitively know a layout shift just happened.
1032
+ */
1033
+ reflowBubbles() {
1034
+ this.renderer.reflowBubbles?.();
1035
+ }
1036
+ /**
1037
+ * Re-resolve every bubble's anchor against the current DOM and
1038
+ * re-render. Use this AFTER a page-level content change (saved
1039
+ * edit, in-place rewrite, etc.) where the cached anchor ranges
1040
+ * are now stale — `reflowBubbles()` only re-measures positions of
1041
+ * the EXISTING anchors and would leave them clustered at wherever
1042
+ * the disappeared element used to be.
1043
+ *
1044
+ * Pushes the current comment list back through the renderer's
1045
+ * `renderBubbles` + `renderSidebar` pipeline; the bubble layer's
1046
+ * repaint re-runs anchor resolution from scratch and emits the
1047
+ * `bubbles:dangling` event so the sidebar can mark unresolved
1048
+ * comments.
1049
+ */
1050
+ repositionBubbles() {
1051
+ this.refreshBubbles();
1052
+ this.refreshSidebar();
1053
+ }
1054
+ /**
1055
+ * Programmatically hide or show the bubble overlay. Orthogonal to
1056
+ * the user's toolbar toggle — both have to be "shown" for bubbles
1057
+ * to appear. Use this when the host puts the page into a state
1058
+ * where anchors can't resolve (admin's raw textarea editor mode,
1059
+ * a lazy viewer hydrating), then pair the un-hide with
1060
+ * `repositionBubbles()` so the layer reseats against the new DOM.
1061
+ *
1062
+ * `animationMs` overrides the fade duration for this transition
1063
+ * (default 150ms). Pass `0` to hide/show instantly — handy when
1064
+ * the host page is mid-animation and a cross-fade would visually
1065
+ * compete (e.g. width toggles that re-flow anchored content).
1066
+ *
1067
+ * No-op for renderers that don't implement an overlay layer.
1068
+ */
1069
+ setBubblesHidden(hidden, animationMs) {
1070
+ this.renderer.setBubblesHidden?.(hidden, animationMs);
1071
+ }
1072
+ /**
1073
+ * Show a transient notification in the bottom-right corner.
1074
+ * Stacks with other in-flight messages and auto-dismisses after
1075
+ * `durationMs` (default 2000; pass 0 to pin until the user
1076
+ * clicks). `kind` colors the left-edge stripe (info/success/error).
1077
+ *
1078
+ * The underlying surface is the renderer's Toaster, driven by the
1079
+ * `toast:show` bus channel. This method is the canonical entry
1080
+ * point for widget code that wants to fire a notification; both
1081
+ * vanilla calls and framework-wrapper consumers reach the same
1082
+ * stack.
1083
+ */
1084
+ notify(message, opts) {
1085
+ this.bus.emit('toast:show', {
1086
+ kind: opts?.kind ?? 'info',
1087
+ message,
1088
+ duration: opts?.durationMs,
1089
+ });
1090
+ }
1091
+ }
1092
+ //# sourceMappingURL=PageLoop.js.map