@hansenexus/hud 0.3.0 → 0.4.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 (54) hide show
  1. package/README.md +186 -4
  2. package/dist/access-CNvJOwKM.d.ts +54 -0
  3. package/dist/capabilities-CjulXFQX.js +189 -0
  4. package/dist/{define-hud-BHQR8Lbv.d.ts → define-hud-BBzsSl8b.d.ts} +6 -1
  5. package/dist/env-DfxpPfs9.js +28 -0
  6. package/dist/env-vEjyJfPW.d.ts +17 -0
  7. package/dist/force-state-BaqWsmrf.js +153 -0
  8. package/dist/force-state-CCqoTfVB.d.ts +23 -0
  9. package/dist/handler-WmE4Lu7l.d.ts +39 -0
  10. package/dist/index-BYsdS-7d.d.ts +55 -0
  11. package/dist/index.d.ts +6 -36
  12. package/dist/index.js +119 -202
  13. package/dist/marker-C66LxOhJ.d.ts +9 -0
  14. package/dist/marker-CMrvxPmS.d.ts +12 -0
  15. package/dist/marker-DjeGvL-F.js +9 -0
  16. package/dist/marker-uZYPOJ4M.js +12 -0
  17. package/dist/markers.d.ts +3 -0
  18. package/dist/markers.js +3 -0
  19. package/dist/next.d.ts +3 -18
  20. package/dist/next.js +3 -28
  21. package/dist/payload-CLKDVZWl.d.ts +68 -0
  22. package/dist/plugins/access/server.d.ts +122 -0
  23. package/dist/plugins/access/server.js +339 -0
  24. package/dist/plugins/access.d.ts +41 -0
  25. package/dist/plugins/access.js +250 -0
  26. package/dist/plugins/agent/server.d.ts +129 -0
  27. package/dist/plugins/agent/server.js +429 -0
  28. package/dist/plugins/agent.d.ts +37 -0
  29. package/dist/plugins/agent.js +411 -0
  30. package/dist/plugins/design.d.ts +125 -0
  31. package/dist/plugins/design.js +619 -0
  32. package/dist/plugins/observe.d.ts +64 -0
  33. package/dist/plugins/observe.js +617 -0
  34. package/dist/plugins/ops/server.d.ts +82 -0
  35. package/dist/plugins/ops/server.js +380 -0
  36. package/dist/plugins/ops.d.ts +26 -0
  37. package/dist/plugins/ops.js +362 -0
  38. package/dist/plugins/vitals.js +3 -86
  39. package/dist/{plugins-1QmTmZyL.d.ts → plugins-CQ_0w1ot.d.ts} +39 -1
  40. package/dist/{plugins-CzUiTn_r.js → plugins-DVy_vaYr.js} +3 -1
  41. package/dist/review.d.ts +181 -0
  42. package/dist/review.js +1017 -0
  43. package/dist/selector-NSYSe7AZ.js +28 -0
  44. package/dist/server.d.ts +3 -39
  45. package/dist/server.js +1 -1
  46. package/dist/shortcut-U6ftVY06.js +222 -0
  47. package/dist/state-boundary.d.ts +26 -0
  48. package/dist/state-boundary.js +61 -0
  49. package/dist/styles--slk86ZQ.js +332 -0
  50. package/dist/types-DADBRiGk.d.ts +50 -0
  51. package/dist/types-QJUajwWq.js +35 -0
  52. package/dist/types-f3jlNxzw.d.ts +92 -0
  53. package/dist/vitals-panel-Dvc5dnR-.js +87 -0
  54. package/package.json +23 -3
package/dist/review.js ADDED
@@ -0,0 +1,1017 @@
1
+ import { n as buildAnchorSelector, t as anchorLabelFor } from "./selector-NSYSe7AZ.js";
2
+ import { i as PICKER_CSS, n as ShadowHost, r as ElementPicker, t as HUD_CSS } from "./styles--slk86ZQ.js";
3
+ import { t as HUD_REVIEW_MARKER } from "./marker-DjeGvL-F.js";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { ConvexClient } from "convex/browser";
7
+ import { makeFunctionReference } from "convex/server";
8
+ //#region src/review/anchor/viewport.ts
9
+ /** Tailwind's `md` (768) and `xl` (1280) — the breakpoints the apps already lay out against. */
10
+ const BUCKET_BREAKPOINTS = {
11
+ tablet: 768,
12
+ desktop: 1280
13
+ };
14
+ function viewportBucket(width) {
15
+ if (width < BUCKET_BREAKPOINTS.tablet) return "mobile";
16
+ if (width < BUCKET_BREAKPOINTS.desktop) return "tablet";
17
+ return "desktop";
18
+ }
19
+ const BUCKET_LABEL = {
20
+ mobile: "Mobil",
21
+ tablet: "Tablet",
22
+ desktop: "Desktop"
23
+ };
24
+ //#endregion
25
+ //#region src/review/anchor/capture.ts
26
+ /**
27
+ * Ids that are regenerated on every mount. Radix hands its triggers ids like
28
+ * `radix-:r1h:`, so without this denylist the "most stable" strategy would be
29
+ * the least stable while claiming otherwise.
30
+ */
31
+ const GENERATED_ID = /^(radix-|headlessui-|react-aria|:r[0-9a-z]+:|\d)/i;
32
+ /** Attributes worth trying as a stable handle, most intentional first. */
33
+ const STABLE_ATTRS = [
34
+ "data-testid",
35
+ "data-review-id",
36
+ "data-slot"
37
+ ];
38
+ /** How far up to look for a stable ancestor before giving up. */
39
+ const STABLE_ANCESTOR_DEPTH = 3;
40
+ /** Longer than this and the element's text identifies a whole section, not an element. */
41
+ const MAX_TEXT_SOURCE = 400;
42
+ const MAX_TEXT_PREFIX = 120;
43
+ function escapeIdent(value) {
44
+ const css = globalThis.CSS;
45
+ return css?.escape ? css.escape(value) : value.replace(/["\\]/g, "\\$&");
46
+ }
47
+ function escapeAttrValue(value) {
48
+ return value.replace(/["\\]/g, "\\$&");
49
+ }
50
+ function isGeneratedId(value) {
51
+ return value.length === 0 || GENERATED_ID.test(value);
52
+ }
53
+ /**
54
+ * A stable selector for `el` itself, or null.
55
+ *
56
+ * Uniqueness is checked, not assumed: shadcn stamps `data-slot="button"` on
57
+ * every button in the tree, so an unchecked attribute selector would be a
58
+ * stable-looking handle onto forty different nodes.
59
+ */
60
+ function stableSelectorFor(el, doc) {
61
+ const candidates = [];
62
+ const id = el.getAttribute("id");
63
+ if (id && !isGeneratedId(id)) candidates.push(`#${escapeIdent(id)}`);
64
+ for (const attr of STABLE_ATTRS) {
65
+ const value = el.getAttribute(attr);
66
+ if (value && !isGeneratedId(value)) candidates.push(`[${attr}="${escapeAttrValue(value)}"]`);
67
+ }
68
+ for (const candidate of candidates) try {
69
+ if (doc.querySelectorAll(candidate).length === 1) return candidate;
70
+ } catch {}
71
+ return null;
72
+ }
73
+ function findStableAncestor(el, doc) {
74
+ let node = el;
75
+ for (let depth = 0; node && depth <= STABLE_ANCESTOR_DEPTH; depth++) {
76
+ const selector = stableSelectorFor(node, doc);
77
+ if (selector) return {
78
+ selector,
79
+ node
80
+ };
81
+ node = node.parentElement;
82
+ }
83
+ return null;
84
+ }
85
+ function normaliseText(value) {
86
+ return value.trim().replace(/\s+/g, " ");
87
+ }
88
+ function textPrefixFor(el) {
89
+ const raw = el.textContent ?? "";
90
+ if (raw.length === 0 || raw.length > MAX_TEXT_SOURCE) return void 0;
91
+ const text = normaliseText(raw);
92
+ return text.length > 0 ? text.slice(0, MAX_TEXT_PREFIX) : void 0;
93
+ }
94
+ /** Attributes that identify an element which carries no text of its own. */
95
+ const SIGNATURE_ATTRS = [
96
+ "aria-label",
97
+ "alt",
98
+ "title",
99
+ "name",
100
+ "src",
101
+ "href"
102
+ ];
103
+ /**
104
+ * A cheap identity for text-less elements. `src`/`href` are reduced to their
105
+ * last path segment so a CDN hash or a locale prefix does not invalidate it.
106
+ */
107
+ function signatureFor(el) {
108
+ for (const attr of SIGNATURE_ATTRS) {
109
+ const raw = el.getAttribute(attr);
110
+ if (!raw) continue;
111
+ const value = attr === "src" || attr === "href" ? (raw.split("?")[0] ?? "").split("/").filter(Boolean).pop() : normaliseText(raw);
112
+ if (value) return `${attr}=${value.slice(0, 80)}`;
113
+ }
114
+ }
115
+ function clamp01(value) {
116
+ if (!Number.isFinite(value)) return .5;
117
+ return Math.min(1, Math.max(0, value));
118
+ }
119
+ function round(value, places = 4) {
120
+ return Number(value.toFixed(places));
121
+ }
122
+ /**
123
+ * Snapshot everything needed to find this element again on a later render, at a
124
+ * possibly different scroll offset, viewport and content revision.
125
+ */
126
+ function capturePinAnchor(el, point) {
127
+ const doc = el.ownerDocument;
128
+ const win = doc.defaultView;
129
+ const root = doc.documentElement;
130
+ const rect = el.getBoundingClientRect();
131
+ const scrollX = win?.scrollX ?? 0;
132
+ const scrollY = win?.scrollY ?? 0;
133
+ const viewportWidth = win?.innerWidth ?? root.clientWidth ?? 0;
134
+ const viewportHeight = win?.innerHeight ?? root.clientHeight ?? 0;
135
+ const docWidth = Math.max(root.scrollWidth, viewportWidth, 1);
136
+ const docHeight = Math.max(root.scrollHeight, viewportHeight, 1);
137
+ const pct = (part, whole) => whole > 0 ? round(part / whole * 100, 1) : 0;
138
+ const anchor = {
139
+ tag: el.tagName.toLowerCase(),
140
+ selector: buildAnchorSelector(el, doc.body),
141
+ label: anchorLabelFor(el),
142
+ offsetX: point && rect.width > 0 ? clamp01((point.clientX - rect.left) / rect.width) : .5,
143
+ offsetY: point && rect.height > 0 ? clamp01((point.clientY - rect.top) / rect.height) : .5,
144
+ docX: round(clamp01((rect.left + scrollX) / docWidth)),
145
+ docY: round(clamp01((rect.top + scrollY) / docHeight)),
146
+ rect: {
147
+ x: pct(rect.left, viewportWidth),
148
+ y: pct(rect.top, viewportHeight),
149
+ width: pct(rect.width, viewportWidth),
150
+ height: pct(rect.height, viewportHeight)
151
+ },
152
+ viewportWidth,
153
+ viewportHeight,
154
+ scrollY,
155
+ viewportBucket: viewportBucket(viewportWidth)
156
+ };
157
+ const stable = findStableAncestor(el, doc);
158
+ if (stable) {
159
+ anchor.stableId = stable.selector;
160
+ anchor.selectorFromStable = stable.node === el ? "" : buildAnchorSelector(el, stable.node);
161
+ }
162
+ const textPrefix = textPrefixFor(el);
163
+ if (textPrefix) anchor.textPrefix = textPrefix;
164
+ const signature = signatureFor(el);
165
+ if (signature) anchor.signature = signature;
166
+ return anchor;
167
+ }
168
+ //#endregion
169
+ //#region src/review/anchor/resolve.ts
170
+ /**
171
+ * Is this element something a pin can actually point at?
172
+ *
173
+ * Deliberately not `offsetParent !== null`: that is null for every
174
+ * `position: fixed` element, which would exile pins on sticky headers and
175
+ * cookie banners — exactly the chrome clients comment on most.
176
+ */
177
+ function isUsable(el) {
178
+ if (!el.isConnected) return false;
179
+ const rect = el.getBoundingClientRect();
180
+ return rect.width > 0 || rect.height > 0;
181
+ }
182
+ /**
183
+ * Does this candidate look like the element the pin was planted on?
184
+ *
185
+ * A tag check alone is not enough, and assuming otherwise is the single
186
+ * sharpest edge here: these pages render Convex-backed lists, so inserting one
187
+ * row shifts every `nth-of-type` below it and the stored path then resolves to
188
+ * a *different node of the same tag*. The resolver would return it with an
189
+ * "exact" badge and be confidently wrong — worse than returning nothing,
190
+ * because nothing is visibly a miss and a wrong element is not.
191
+ *
192
+ * So a path hit must also match whatever identity we stored: the leading text,
193
+ * or for text-less elements (icon buttons, images, inputs) an attribute
194
+ * signature. With neither available the path stands alone, which is the
195
+ * weakest case and unavoidable.
196
+ */
197
+ function corroborates(el, anchor) {
198
+ if (el.tagName.toLowerCase() !== anchor.tag) return false;
199
+ if (anchor.textPrefix) return normaliseText(el.textContent ?? "").startsWith(anchor.textPrefix);
200
+ if (anchor.signature) return signatureFor(el) === anchor.signature;
201
+ return true;
202
+ }
203
+ function query(root, selector) {
204
+ if (!selector) return null;
205
+ try {
206
+ return root.querySelector(selector);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+ function queryAll(root, selector) {
212
+ if (!selector) return [];
213
+ try {
214
+ return [...root.querySelectorAll(selector)];
215
+ } catch {
216
+ return [];
217
+ }
218
+ }
219
+ /** Strategy 1 — the stored path, corroborated. */
220
+ function byExactSelector(anchor, doc) {
221
+ const el = query(doc, anchor.selector);
222
+ if (!el || !isUsable(el) || !corroborates(el, anchor)) return null;
223
+ return el;
224
+ }
225
+ /**
226
+ * Strategy 2 — a unique, non-generated handle, then the path *below* it.
227
+ *
228
+ * Storing only the handle would make this a coarse fallback; storing the tail
229
+ * as well makes it independent of everything above the handle, which is
230
+ * precisely what content-driven reshuffles change. The tail is corroborated
231
+ * too — it carries the same `nth-of-type` fragility as the full path, just
232
+ * over a shorter distance.
233
+ */
234
+ function byStableId(anchor, doc) {
235
+ if (!anchor.stableId) return null;
236
+ const host = query(doc, anchor.stableId);
237
+ if (!host) return null;
238
+ const candidate = anchor.selectorFromStable ? query(host, anchor.selectorFromStable) : host;
239
+ if (!candidate || !isUsable(candidate) || !corroborates(candidate, anchor)) return null;
240
+ return candidate;
241
+ }
242
+ /**
243
+ * Strategy 3 — scan every element of the same tag for the one whose identity
244
+ * matches, ignoring structure entirely.
245
+ *
246
+ * This is the strategy that survives a reshuffle, because it does not look at
247
+ * the path at all. It matches on whichever identity was stored — leading text,
248
+ * or an attribute signature for text-less elements like icon buttons — so both
249
+ * kinds of element are recoverable and not just the ones with words in them.
250
+ *
251
+ * It only runs when there IS an identity: with neither stored, `corroborates`
252
+ * accepts every element of that tag, and picking the nearest one would be a
253
+ * guess dressed as a result.
254
+ *
255
+ * Text matching is locale-dependent by nature — a pin planted on the German
256
+ * site will not match by text on `/en` — which is why a pin also stores its
257
+ * locale.
258
+ */
259
+ function byIdentity(anchor, doc) {
260
+ if (!anchor.textPrefix && !anchor.signature) return null;
261
+ const matches = queryAll(doc, anchor.tag).filter((el) => isUsable(el) && corroborates(el, anchor));
262
+ if (matches.length === 0) return null;
263
+ if (matches.length === 1) return matches[0] ?? null;
264
+ const root = doc.documentElement;
265
+ const win = doc.defaultView;
266
+ const docHeight = Math.max(root.scrollHeight, win?.innerHeight ?? 0, 1);
267
+ const scrollY = win?.scrollY ?? 0;
268
+ let best = null;
269
+ let bestDelta = Number.POSITIVE_INFINITY;
270
+ for (const el of matches) {
271
+ const docY = (el.getBoundingClientRect().top + scrollY) / docHeight;
272
+ const delta = Math.abs(docY - anchor.docY);
273
+ if (delta < bestDelta) {
274
+ bestDelta = delta;
275
+ best = el;
276
+ }
277
+ }
278
+ return best;
279
+ }
280
+ /**
281
+ * Locate a stored anchor on the current document.
282
+ *
283
+ * Returns null rather than guessing. A null resolution is a first-class
284
+ * outcome, not an error: the pin is shown in the "Ohne Position" list with its
285
+ * screenshot, where it stays actionable — and a pin that stops resolving is
286
+ * itself a signal that the thing it complained about is gone.
287
+ */
288
+ function resolveAnchor(anchor, doc = document) {
289
+ const exact = byExactSelector(anchor, doc);
290
+ if (exact) return {
291
+ element: exact,
292
+ strategy: "exact"
293
+ };
294
+ const stable = byStableId(anchor, doc);
295
+ if (stable) return {
296
+ element: stable,
297
+ strategy: "stable"
298
+ };
299
+ const identity = byIdentity(anchor, doc);
300
+ if (identity) return {
301
+ element: identity,
302
+ strategy: "identity"
303
+ };
304
+ return null;
305
+ }
306
+ //#endregion
307
+ //#region src/review/backend.ts
308
+ /**
309
+ * Function references by name rather than a generated `api` object: the
310
+ * widget is consumed by apps that cannot import the review app's
311
+ * `convex/_generated`. The names are a contract with that deployment.
312
+ */
313
+ const fn = {
314
+ redeemGrant: makeFunctionReference("access:redeemGrant"),
315
+ whoami: makeFunctionReference("access:whoami"),
316
+ listForRoute: makeFunctionReference("pins:listForRoute"),
317
+ plant: makeFunctionReference("pins:plant")
318
+ };
319
+ /** The review app's Convex deployment at `url`. */
320
+ function convexReview(url) {
321
+ const client = new ConvexClient(url);
322
+ return {
323
+ redeemGrant: (grant) => client.mutation(fn.redeemGrant, { grant }),
324
+ watchReviewer: (sessionToken, onChange) => client.onUpdate(fn.whoami, { sessionToken }, (value) => onChange(value), () => onChange(null)),
325
+ watchPins: (sessionToken, route, onChange) => client.onUpdate(fn.listForRoute, {
326
+ sessionToken,
327
+ route
328
+ }, (value) => onChange(value ?? []), () => onChange([])),
329
+ plant: (pin) => client.mutation(fn.plant, { ...pin }),
330
+ close: () => {
331
+ client.close();
332
+ }
333
+ };
334
+ }
335
+ //#endregion
336
+ //#region src/review/page-inset.ts
337
+ const STYLE_ID = "hn-review-inset";
338
+ /**
339
+ * Narrow the page so a docked panel covers nothing. OFF by default — see below.
340
+ *
341
+ * `margin-right` on the root element does narrow the layout correctly: the
342
+ * document box and every in-flow element follow it. What does NOT follow is
343
+ * the thing that matters most.
344
+ *
345
+ * **Media queries evaluate against the viewport, not the narrowed box.** Inset
346
+ * elbe-akustik by 360px at a 1183px viewport and the page still believes it is
347
+ * a 1183px desktop: it renders the full desktop nav and then squeezes it into
348
+ * 808px, so the booking CTA overflows its own header. The reviewer is then
349
+ * looking at a layout the client will never see, and can file a bug this tool
350
+ * caused.
351
+ *
352
+ * It also quietly corrupts the pins: `viewportBucket` reads
353
+ * `window.innerWidth`, which the inset does not change, so a pin planted while
354
+ * inset is tagged with a bucket that does not match the layout it was planted
355
+ * against.
356
+ *
357
+ * So the default is an overlay and the page under review is left exactly as the
358
+ * client sees it. The inset stays available as a deliberate per-viewer toggle,
359
+ * for reading long pin threads against a page that does reflow cleanly.
360
+ *
361
+ * Second caveat if you do turn it on: `position: fixed` site chrome does not
362
+ * move with it either — it is positioned against the viewport — so the chat
363
+ * launcher and ProvenExpert badge stay put behind the panel.
364
+ */
365
+ function applyPageInset(px) {
366
+ if (typeof document === "undefined") return;
367
+ const root = document.documentElement;
368
+ if (px <= 0) {
369
+ root.style.removeProperty("margin-right");
370
+ root.style.removeProperty("transition");
371
+ document.getElementById(STYLE_ID)?.remove();
372
+ return;
373
+ }
374
+ if (!document.getElementById(STYLE_ID)) {
375
+ const style = document.createElement("style");
376
+ style.id = STYLE_ID;
377
+ style.textContent = ":root{scrollbar-gutter:stable}";
378
+ document.head.append(style);
379
+ }
380
+ root.style.transition = "margin-right 180ms ease";
381
+ root.style.marginRight = `${px}px`;
382
+ }
383
+ /** Docked width, or 0 when the viewport is too narrow to give any away. */
384
+ function dockWidthFor(viewportWidth) {
385
+ return viewportWidth < 768 ? 0 : Math.min(360, Math.round(viewportWidth * .32));
386
+ }
387
+ const PREF_KEY = "hn-review:inset";
388
+ /** Whether this viewer asked for the page to be narrowed. Default off. */
389
+ function readInsetPref() {
390
+ try {
391
+ return localStorage.getItem(PREF_KEY) === "1";
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+ function writeInsetPref(on) {
397
+ try {
398
+ localStorage.setItem(PREF_KEY, on ? "1" : "0");
399
+ } catch {}
400
+ }
401
+ //#endregion
402
+ //#region src/review/pin-layer.tsx
403
+ /**
404
+ * Renders the pins for this page inside the review's shadow root.
405
+ *
406
+ * Three outcomes per pin and no fourth: placed, placed with a badge saying the
407
+ * anchor moved, or handed to `onUnplaced` for the "Ohne Position" list. A pin
408
+ * is never silently dropped and never silently moved onto a neighbour.
409
+ *
410
+ * The shadow host is `position: fixed`, so markers sit at viewport
411
+ * coordinates and are re-measured on scroll, at most once per frame.
412
+ */
413
+ function PinLayer({ pins, activeId, onSelect, onUnplaced }) {
414
+ const [placed, setPlaced] = useState([]);
415
+ const frame = useRef(null);
416
+ const reposition = useCallback(() => {
417
+ const bucket = viewportBucket(window.innerWidth);
418
+ const next = [];
419
+ const missing = [];
420
+ for (const pin of pins) {
421
+ if (pin.viewportBucket !== bucket) {
422
+ missing.push(pin);
423
+ continue;
424
+ }
425
+ const hit = resolveAnchor(pin.anchor, document);
426
+ if (!hit) {
427
+ missing.push(pin);
428
+ continue;
429
+ }
430
+ const rect = hit.element.getBoundingClientRect();
431
+ next.push({
432
+ pin,
433
+ x: rect.left + rect.width * pin.anchor.offsetX,
434
+ y: rect.top + rect.height * pin.anchor.offsetY,
435
+ strategy: hit.strategy
436
+ });
437
+ }
438
+ setPlaced(next);
439
+ onUnplaced(missing);
440
+ }, [pins, onUnplaced]);
441
+ const schedule = useCallback(() => {
442
+ if (frame.current !== null) return;
443
+ frame.current = requestAnimationFrame(() => {
444
+ frame.current = null;
445
+ reposition();
446
+ });
447
+ }, [reposition]);
448
+ useEffect(() => {
449
+ reposition();
450
+ window.addEventListener("scroll", schedule, {
451
+ capture: true,
452
+ passive: true
453
+ });
454
+ window.addEventListener("resize", schedule);
455
+ const resizeObserver = new ResizeObserver(schedule);
456
+ resizeObserver.observe(document.documentElement);
457
+ let mutationTimer = null;
458
+ const mutationObserver = new MutationObserver(() => {
459
+ if (mutationTimer) clearTimeout(mutationTimer);
460
+ mutationTimer = setTimeout(schedule, 200);
461
+ });
462
+ mutationObserver.observe(document.body, {
463
+ childList: true,
464
+ subtree: true
465
+ });
466
+ return () => {
467
+ window.removeEventListener("scroll", schedule, { capture: true });
468
+ window.removeEventListener("resize", schedule);
469
+ resizeObserver.disconnect();
470
+ mutationObserver.disconnect();
471
+ if (mutationTimer) clearTimeout(mutationTimer);
472
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
473
+ frame.current = null;
474
+ };
475
+ }, [reposition, schedule]);
476
+ return /* @__PURE__ */ jsx("div", {
477
+ className: "pin-layer",
478
+ children: placed.map(({ pin, x, y, strategy }) => /* @__PURE__ */ jsx("button", {
479
+ type: "button",
480
+ className: "pin",
481
+ "data-lane": pin.lane,
482
+ "data-moved": strategy === "exact" ? void 0 : "",
483
+ "aria-pressed": activeId === pin._id,
484
+ style: {
485
+ left: x,
486
+ top: y
487
+ },
488
+ onClick: () => onSelect(activeId === pin._id ? null : pin._id),
489
+ title: strategy === "exact" ? `#${pin.number} ${pin.authorName}` : `#${pin.number} ${pin.authorName} — Position angenähert (${strategy})`,
490
+ children: pin.number
491
+ }, pin._id))
492
+ });
493
+ }
494
+ //#endregion
495
+ //#region src/review/route.ts
496
+ /**
497
+ * The route a pin belongs to.
498
+ *
499
+ * Locale-stripped on purpose: with `localePrefix: "as-needed"` the German page
500
+ * is `/standorte/wedel` and the English one `/en/standorte/wedel`. Storing the
501
+ * raw path would make those two different routes, so a pin left by a German
502
+ * reviewer would be invisible to anyone reading the English page — and the same
503
+ * layout bug would be reported once per language.
504
+ */
505
+ function routeKey(pathname, locale) {
506
+ const stripped = pathname === `/${locale}` ? "/" : pathname.startsWith(`/${locale}/`) ? pathname.slice(locale.length + 1) : pathname;
507
+ return stripped.length > 1 ? stripped.replace(/\/+$/, "") : "/";
508
+ }
509
+ //#endregion
510
+ //#region src/review/session.ts
511
+ /**
512
+ * The reviewer's session on the reviewed site's own origin.
513
+ *
514
+ * The dashboard lives on a different origin with its own storage, so the same
515
+ * link is redeemed on both. The backend keys the reviewer on the grant, so that
516
+ * is one identity with two sessions rather than two people.
517
+ */
518
+ const KEY = "hn-review:session";
519
+ const NAME_KEY = "hn-review:name";
520
+ function safeGet(key) {
521
+ try {
522
+ return localStorage.getItem(key);
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
527
+ function safeSet(key, value) {
528
+ try {
529
+ localStorage.setItem(key, value);
530
+ } catch {}
531
+ }
532
+ function readToken(slug) {
533
+ return safeGet(`${KEY}:${slug}`);
534
+ }
535
+ function writeToken(slug, token) {
536
+ safeSet(`${KEY}:${slug}`, token);
537
+ }
538
+ function readName() {
539
+ return safeGet(NAME_KEY);
540
+ }
541
+ function writeName(name) {
542
+ safeSet(NAME_KEY, name);
543
+ }
544
+ /**
545
+ * Pull a grant out of `?rv=` and strip it from the URL.
546
+ *
547
+ * Returns it once: the caller redeems it, and the parameter is gone from the
548
+ * address bar before it can be bookmarked, screenshotted or sent to anyone in a
549
+ * Referer header.
550
+ */
551
+ function takeGrantFromUrl() {
552
+ if (typeof window === "undefined") return null;
553
+ const url = new URL(window.location.href);
554
+ const grant = url.searchParams.get("rv");
555
+ if (!grant) return null;
556
+ url.searchParams.delete("rv");
557
+ window.history.replaceState({}, "", url.toString());
558
+ return grant;
559
+ }
560
+ //#endregion
561
+ //#region src/review/review.tsx
562
+ const CSS = HUD_CSS + PICKER_CSS + `
563
+ .hud { --review-client: #2563eb; --review-internal: #9333ea; --review-moved: #fbbf24; }
564
+
565
+ /* Client sites fill every corner (booking CTA, chat launcher, cookie banner,
566
+ rating badge), so the review pill is a tab on the middle of the right edge
567
+ and rides alongside the docked sheet (inline \`right\`). */
568
+ .hud .review-pill {
569
+ top: 50%;
570
+ bottom: auto;
571
+ left: auto;
572
+ right: 0;
573
+ transform: translateY(-50%);
574
+ border-right: 0;
575
+ border-radius: 999px 0 0 999px;
576
+ font-family: var(--hud-font);
577
+ }
578
+ .review-pill .count {
579
+ min-width: 18px;
580
+ padding: 0 5px;
581
+ border-radius: 999px;
582
+ background: var(--hud-accent);
583
+ color: var(--hud-accent-fg);
584
+ font: 600 10px/16px var(--hud-mono);
585
+ text-align: center;
586
+ }
587
+
588
+ /* The German hint is long; let it wrap on a phone instead of running off both edges. */
589
+ .grab-hint {
590
+ width: max-content;
591
+ max-width: calc(100vw - 16px);
592
+ white-space: normal;
593
+ text-align: center;
594
+ }
595
+
596
+ .pin-layer { position: fixed; inset: 0; pointer-events: none; }
597
+ .hud .pin {
598
+ position: fixed;
599
+ transform: translate(-50%, -50%);
600
+ width: 26px;
601
+ height: 26px;
602
+ border-radius: 50%;
603
+ border: 2px solid #ffffff;
604
+ background: var(--review-client);
605
+ color: #ffffff;
606
+ font: 700 12px/1 var(--hud-font);
607
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
608
+ pointer-events: auto;
609
+ }
610
+ .hud .pin[data-lane="internal"] { background: var(--review-internal); }
611
+ .hud .pin[data-moved] { border-color: var(--review-moved); }
612
+ .hud .pin[aria-pressed="true"] { outline: 2px solid var(--hud-fg); outline-offset: 2px; }
613
+
614
+ .sheet {
615
+ position: fixed;
616
+ top: 0;
617
+ right: 0;
618
+ bottom: 0;
619
+ width: 100%;
620
+ overflow-y: auto;
621
+ padding: 16px;
622
+ background: var(--hud-bg);
623
+ color: var(--hud-fg);
624
+ border-left: 1px solid var(--hud-border);
625
+ box-shadow: var(--hud-shadow);
626
+ font-size: 13px;
627
+ pointer-events: auto;
628
+ }
629
+ .sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
630
+ .sheet-head strong { font-size: 14px; }
631
+ .sheet-tools { display: flex; align-items: center; gap: 4px; }
632
+ .hud .toggle {
633
+ padding: 3px 6px;
634
+ border: 1px solid var(--hud-border);
635
+ border-radius: 5px;
636
+ color: var(--hud-fg-muted);
637
+ font-size: 10px;
638
+ font-weight: 600;
639
+ }
640
+ .hud .toggle[aria-pressed="true"] { background: var(--hud-fg); color: var(--hud-bg); }
641
+ .hud .close { padding: 0 4px; font-size: 16px; line-height: 1; }
642
+ .muted { margin: 4px 0 12px; color: var(--hud-fg-muted); font-size: 12px; }
643
+
644
+ .compose textarea {
645
+ display: block;
646
+ width: 100%;
647
+ padding: 8px;
648
+ border: 1px solid var(--hud-border);
649
+ border-radius: 6px;
650
+ background: var(--hud-bg);
651
+ color: var(--hud-fg);
652
+ font: inherit;
653
+ resize: vertical;
654
+ }
655
+ .actions { display: flex; gap: 8px; margin-top: 8px; }
656
+ .hud .primary, .hud .secondary {
657
+ padding: 8px 10px;
658
+ border-radius: 6px;
659
+ font-weight: 600;
660
+ }
661
+ .hud .primary { flex: 1; background: var(--hud-accent); color: var(--hud-accent-fg); }
662
+ .hud .primary:disabled { opacity: 0.45; cursor: not-allowed; }
663
+ .hud .secondary { border: 1px solid var(--hud-border); }
664
+ .hud .start { width: 100%; padding: 10px; background: var(--hud-fg); color: var(--hud-bg); }
665
+
666
+ .pins { margin-top: 16px; }
667
+ .pins ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
668
+ .pins li { padding: 8px; border: 1px solid var(--hud-border); border-radius: 6px; }
669
+ .pins li[data-active] { border-color: var(--hud-accent); }
670
+ .pins.unplaced li { border-style: dashed; }
671
+ .pins p { margin: 4px 0 0; }
672
+ `;
673
+ /**
674
+ * The production-safe review widget: a pill, the element picker and a docked
675
+ * review sheet with the page's pins.
676
+ *
677
+ * Everything arrives as a prop and nothing is read from `process.env`. That is
678
+ * what makes the runtime gate work: an app that ships one image to two
679
+ * productions has identical build-time flags on both, so a build flag could not
680
+ * switch the widget off where it must be off. The mounting app decides, per
681
+ * deployment, by passing `convexUrl` or not.
682
+ *
683
+ * Nothing renders, and no DOM is touched, until a reviewer session exists:
684
+ * a visitor without a review link never sees a pill.
685
+ */
686
+ function Review({ convexUrl, projectSlug, locale, backend }) {
687
+ const [client, setClient] = useState(null);
688
+ useEffect(() => {
689
+ if (backend) {
690
+ setClient(backend);
691
+ return;
692
+ }
693
+ if (!convexUrl) {
694
+ setClient(null);
695
+ return;
696
+ }
697
+ const convex = convexReview(convexUrl);
698
+ setClient(convex);
699
+ return () => {
700
+ convex.close?.();
701
+ setClient(null);
702
+ };
703
+ }, [backend, convexUrl]);
704
+ if (!client) return null;
705
+ return /* @__PURE__ */ jsx(Widget, {
706
+ backend: client,
707
+ projectSlug,
708
+ locale
709
+ });
710
+ }
711
+ function Widget({ backend, projectSlug, locale }) {
712
+ const [token, setToken] = useState(null);
713
+ const [ready, setReady] = useState(false);
714
+ const [mode, setMode] = useState("idle");
715
+ const [draft, setDraft] = useState(null);
716
+ const [body, setBody] = useState("");
717
+ const [name, setName] = useState("");
718
+ const [failed, setFailed] = useState(false);
719
+ const [activeId, setActiveId] = useState(null);
720
+ const [unplaced, setUnplaced] = useState([]);
721
+ const [open, setOpen] = useState(false);
722
+ const [route, setRoute] = useState("/");
723
+ useEffect(() => {
724
+ setRoute(routeKey(window.location.pathname, locale));
725
+ setName(readName() ?? "");
726
+ const existing = readToken(projectSlug);
727
+ const grant = takeGrantFromUrl();
728
+ if (!grant) {
729
+ setToken(existing);
730
+ setReady(true);
731
+ return;
732
+ }
733
+ backend.redeemGrant(grant).then((session) => {
734
+ writeToken(session.projectSlug, session.sessionToken);
735
+ writeName(session.displayName);
736
+ setToken(session.sessionToken);
737
+ setName(session.displayName);
738
+ }).catch(() => setToken(existing)).finally(() => setReady(true));
739
+ }, [
740
+ backend,
741
+ projectSlug,
742
+ locale
743
+ ]);
744
+ useEffect(() => {
745
+ const id = setInterval(() => {
746
+ const next = routeKey(window.location.pathname, locale);
747
+ setRoute((current) => current === next ? current : next);
748
+ }, 400);
749
+ return () => clearInterval(id);
750
+ }, [locale]);
751
+ const [reviewer, setReviewer] = useState(void 0);
752
+ useEffect(() => {
753
+ setReviewer(void 0);
754
+ if (!token) return;
755
+ return backend.watchReviewer(token, setReviewer);
756
+ }, [backend, token]);
757
+ const [pins, setPins] = useState([]);
758
+ useEffect(() => {
759
+ setPins([]);
760
+ if (!token) return;
761
+ return backend.watchPins(token, route, setPins);
762
+ }, [
763
+ backend,
764
+ token,
765
+ route
766
+ ]);
767
+ const [dockWidth, setDockWidth] = useState(0);
768
+ useEffect(() => {
769
+ const measure = () => setDockWidth(dockWidthFor(window.innerWidth));
770
+ measure();
771
+ window.addEventListener("resize", measure);
772
+ return () => window.removeEventListener("resize", measure);
773
+ }, []);
774
+ const [inset, setInset] = useState(false);
775
+ useEffect(() => setInset(readInsetPref()), []);
776
+ useEffect(() => {
777
+ applyPageInset(open && inset ? dockWidth : 0);
778
+ return () => applyPageInset(0);
779
+ }, [
780
+ open,
781
+ dockWidth,
782
+ inset
783
+ ]);
784
+ const onPick = useCallback((element, point) => {
785
+ setDraft(capturePinAnchor(element, point));
786
+ setMode("composing");
787
+ setFailed(false);
788
+ setOpen(true);
789
+ }, []);
790
+ const onSelect = useCallback((id) => {
791
+ setActiveId(id);
792
+ if (id) setOpen(true);
793
+ }, []);
794
+ const submit = useCallback(async () => {
795
+ if (!token || !draft || !body.trim()) return;
796
+ try {
797
+ await backend.plant({
798
+ sessionToken: token,
799
+ route,
800
+ rawPath: window.location.pathname,
801
+ locale,
802
+ body,
803
+ anchor: draft
804
+ });
805
+ } catch {
806
+ setFailed(true);
807
+ return;
808
+ }
809
+ setBody("");
810
+ setDraft(null);
811
+ setFailed(false);
812
+ setMode("idle");
813
+ }, [
814
+ backend,
815
+ token,
816
+ draft,
817
+ body,
818
+ route,
819
+ locale
820
+ ]);
821
+ const discard = useCallback(() => {
822
+ setDraft(null);
823
+ setBody("");
824
+ setFailed(false);
825
+ setMode("idle");
826
+ }, []);
827
+ if (!ready || !token || reviewer === null) return null;
828
+ const docked = open ? dockWidth : 0;
829
+ const startPick = () => {
830
+ setMode("picking");
831
+ if (dockWidth === 0) setOpen(false);
832
+ };
833
+ const pickToggle = () => mode === "picking" ? setMode("idle") : startPick();
834
+ return /* @__PURE__ */ jsxs(ShadowHost, {
835
+ css: CSS,
836
+ marker: HUD_REVIEW_MARKER,
837
+ children: [
838
+ /* @__PURE__ */ jsx(PinLayer, {
839
+ activeId,
840
+ onSelect,
841
+ onUnplaced: setUnplaced,
842
+ pins
843
+ }),
844
+ /* @__PURE__ */ jsx(ElementPicker, {
845
+ active: mode === "picking",
846
+ hint: "Element anklicken, das du kommentieren willst · Esc bricht ab",
847
+ onCancel: () => setMode("idle"),
848
+ onPick
849
+ }),
850
+ open && /* @__PURE__ */ jsx(Sheet, {
851
+ width: dockWidth,
852
+ mode,
853
+ draft,
854
+ body,
855
+ failed,
856
+ name: name || reviewer?.displayName || "Gast",
857
+ pins,
858
+ unplaced,
859
+ activeId,
860
+ inset,
861
+ onBody: setBody,
862
+ onStartPick: startPick,
863
+ onSubmit: submit,
864
+ onDiscard: discard,
865
+ onClose: () => setOpen(false),
866
+ onToggleInset: () => setInset((v) => {
867
+ writeInsetPref(!v);
868
+ return !v;
869
+ })
870
+ }),
871
+ /* @__PURE__ */ jsxs("div", {
872
+ className: "pill review-pill",
873
+ role: "toolbar",
874
+ "aria-label": "Review",
875
+ style: { right: docked },
876
+ children: [/* @__PURE__ */ jsxs("button", {
877
+ type: "button",
878
+ className: "pill-item",
879
+ "aria-pressed": open,
880
+ onClick: () => setOpen((v) => !v),
881
+ children: ["Review", pins.length > 0 && /* @__PURE__ */ jsx("span", {
882
+ className: "count",
883
+ children: pins.length
884
+ })]
885
+ }), /* @__PURE__ */ jsx("button", {
886
+ type: "button",
887
+ className: "pill-item",
888
+ "aria-label": "Element kommentieren",
889
+ "aria-pressed": mode === "picking",
890
+ title: "Element kommentieren",
891
+ onClick: pickToggle,
892
+ children: "⌖"
893
+ })]
894
+ })
895
+ ]
896
+ });
897
+ }
898
+ function Sheet({ width, mode, draft, body, failed, name, pins, unplaced, activeId, inset, onBody, onStartPick, onSubmit, onDiscard, onClose, onToggleInset }) {
899
+ return /* @__PURE__ */ jsxs("aside", {
900
+ className: "sheet",
901
+ "aria-label": "Review",
902
+ style: width > 0 ? { width } : void 0,
903
+ children: [
904
+ /* @__PURE__ */ jsxs("header", {
905
+ className: "sheet-head",
906
+ children: [/* @__PURE__ */ jsx("strong", { children: "Review" }), /* @__PURE__ */ jsxs("span", {
907
+ className: "sheet-tools",
908
+ children: [width > 0 && /* @__PURE__ */ jsx("button", {
909
+ type: "button",
910
+ className: "toggle",
911
+ "aria-pressed": inset,
912
+ onClick: onToggleInset,
913
+ title: inset ? "Seite wieder voll breit zeigen" : "Seite schmaler machen. Achtung: die Seite rechnet weiter mit der vollen Fensterbreite, das Layout kann dadurch anders aussehen als beim Kunden.",
914
+ children: "schmaler"
915
+ }), /* @__PURE__ */ jsx("button", {
916
+ type: "button",
917
+ className: "close",
918
+ "aria-label": "Schließen",
919
+ onClick: onClose,
920
+ children: "×"
921
+ })]
922
+ })]
923
+ }),
924
+ /* @__PURE__ */ jsxs("p", {
925
+ className: "muted",
926
+ children: ["Angemeldet als ", name]
927
+ }),
928
+ mode === "composing" && draft ? /* @__PURE__ */ jsxs("div", {
929
+ className: "compose",
930
+ children: [
931
+ /* @__PURE__ */ jsx("p", {
932
+ className: "muted",
933
+ children: draft.label
934
+ }),
935
+ /* @__PURE__ */ jsx("textarea", {
936
+ "aria-label": "Kommentar",
937
+ placeholder: "Was stimmt hier nicht?",
938
+ rows: 4,
939
+ value: body,
940
+ onChange: (event) => onBody(event.target.value)
941
+ }),
942
+ failed && /* @__PURE__ */ jsx("p", {
943
+ className: "muted",
944
+ children: "Speichern fehlgeschlagen. Bitte noch einmal versuchen."
945
+ }),
946
+ /* @__PURE__ */ jsxs("div", {
947
+ className: "actions",
948
+ children: [/* @__PURE__ */ jsx("button", {
949
+ type: "button",
950
+ className: "primary",
951
+ disabled: !body.trim(),
952
+ onClick: onSubmit,
953
+ children: "Pin setzen"
954
+ }), /* @__PURE__ */ jsx("button", {
955
+ type: "button",
956
+ className: "secondary",
957
+ onClick: onDiscard,
958
+ children: "Verwerfen"
959
+ })]
960
+ })
961
+ ]
962
+ }) : /* @__PURE__ */ jsx("button", {
963
+ type: "button",
964
+ className: "primary start",
965
+ onClick: onStartPick,
966
+ children: "Element kommentieren"
967
+ }),
968
+ /* @__PURE__ */ jsxs("section", {
969
+ className: "pins",
970
+ children: [/* @__PURE__ */ jsxs("p", {
971
+ className: "muted",
972
+ children: [
973
+ pins.length,
974
+ " Pin",
975
+ pins.length === 1 ? "" : "s",
976
+ " auf dieser Seite"
977
+ ]
978
+ }), /* @__PURE__ */ jsx("ul", { children: pins.map((pin) => /* @__PURE__ */ jsxs("li", {
979
+ "data-active": activeId === pin._id ? "" : void 0,
980
+ children: [
981
+ /* @__PURE__ */ jsxs("strong", { children: ["#", pin.number] }),
982
+ " ",
983
+ pin.authorName,
984
+ /* @__PURE__ */ jsx("p", { children: pin.body })
985
+ ]
986
+ }, pin._id)) })]
987
+ }),
988
+ unplaced.length > 0 && /* @__PURE__ */ jsxs("section", {
989
+ className: "pins unplaced",
990
+ children: [
991
+ /* @__PURE__ */ jsxs("p", {
992
+ className: "muted",
993
+ children: [
994
+ "Ohne Position (",
995
+ unplaced.length,
996
+ ")"
997
+ ]
998
+ }),
999
+ /* @__PURE__ */ jsx("p", {
1000
+ className: "muted",
1001
+ children: "Entweder in einer anderen Ansichtsbreite gesetzt, oder das Element gibt es nicht mehr."
1002
+ }),
1003
+ /* @__PURE__ */ jsx("ul", { children: unplaced.map((pin) => /* @__PURE__ */ jsxs("li", { children: [
1004
+ /* @__PURE__ */ jsxs("strong", { children: ["#", pin.number] }),
1005
+ " ",
1006
+ pin.authorName,
1007
+ " · ",
1008
+ BUCKET_LABEL[pin.viewportBucket],
1009
+ /* @__PURE__ */ jsx("p", { children: pin.body })
1010
+ ] }, pin._id)) })
1011
+ ]
1012
+ })
1013
+ ]
1014
+ });
1015
+ }
1016
+ //#endregion
1017
+ export { HUD_REVIEW_MARKER, Review, capturePinAnchor, convexReview, resolveAnchor, routeKey, viewportBucket };