@reticlehq/browser 2.11.0 → 2.13.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 (63) hide show
  1. package/dist/actions/actions-dom.js +41 -3
  2. package/dist/actions/actions.js +43 -11
  3. package/dist/commands/commands.js +6 -2
  4. package/dist/connect-options.d.ts +18 -1
  5. package/dist/dom/a11y.js +92 -9
  6. package/dist/dom/dom-ignore.d.ts +9 -0
  7. package/dist/dom/dom-ignore.js +32 -3
  8. package/dist/dom/query.js +168 -97
  9. package/dist/dom/refs.d.ts +11 -0
  10. package/dist/dom/refs.js +30 -1
  11. package/dist/dom/snapshot.d.ts +37 -0
  12. package/dist/dom/snapshot.js +141 -37
  13. package/dist/edit-epoch.d.ts +32 -0
  14. package/dist/edit-epoch.js +108 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/observers/animation.js +6 -8
  18. package/dist/observers/context-open.d.ts +12 -0
  19. package/dist/observers/context-open.js +25 -0
  20. package/dist/observers/focus.js +5 -6
  21. package/dist/observers/health.js +6 -9
  22. package/dist/observers/install-all.js +2 -0
  23. package/dist/observers/network-redact.d.ts +13 -0
  24. package/dist/observers/network-redact.js +12 -1
  25. package/dist/observers/network.d.ts +2 -2
  26. package/dist/observers/network.js +24 -17
  27. package/dist/observers/scroll.js +4 -2
  28. package/dist/observers/storage.js +1 -1
  29. package/dist/presenter/presenter-brand.d.ts +4 -18
  30. package/dist/presenter/presenter-brand.js +6 -18
  31. package/dist/presenter/presenter-config.d.ts +11 -8
  32. package/dist/presenter/presenter-config.js +9 -3
  33. package/dist/presenter/presenter-controls.d.ts +0 -3
  34. package/dist/presenter/presenter-controls.js +0 -3
  35. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  36. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  37. package/dist/presenter/presenter-report-copy.js +31 -2
  38. package/dist/presenter/presenter-report-styles.js +24 -0
  39. package/dist/presenter/presenter-report.d.ts +2 -2
  40. package/dist/presenter/presenter-report.js +95 -3
  41. package/dist/presenter/presenter-settings.js +10 -2
  42. package/dist/presenter/presenter-shell-styles.js +20 -1
  43. package/dist/presenter/presenter-shell.d.ts +0 -2
  44. package/dist/presenter/presenter-shell.js +1 -6
  45. package/dist/presenter/presenter.d.ts +13 -0
  46. package/dist/presenter/presenter.js +37 -2
  47. package/dist/registry/auto-testids.d.ts +21 -0
  48. package/dist/registry/auto-testids.js +48 -0
  49. package/dist/registry/capabilities.d.ts +28 -1
  50. package/dist/registry/capabilities.js +59 -7
  51. package/dist/registry/store-adapters.d.ts +1 -1
  52. package/dist/registry/store-adapters.js +6 -1
  53. package/dist/registry/stores.d.ts +4 -0
  54. package/dist/registry/stores.js +35 -0
  55. package/dist/reticle.d.ts +12 -0
  56. package/dist/reticle.js +40 -1
  57. package/dist/transport/unreachable-message.d.ts +7 -0
  58. package/dist/transport/unreachable-message.js +11 -1
  59. package/package.json +4 -7
  60. package/dist/dom/blind-spots.d.ts +0 -13
  61. package/dist/dom/blind-spots.js +0 -29
  62. package/dist/observers/subresources.d.ts +0 -10
  63. package/dist/observers/subresources.js +0 -63
@@ -1,12 +1,12 @@
1
1
  import { BlindSpotKind, EventType, RETICLE_WS_PATH, StreamTransport, StreamDirection, } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
3
  import { isCapturableType, projectBody, withBodyDeadline } from './network-body.js';
4
- import { redactUrl } from './network-redact.js';
4
+ import { redactUrl, netUrlFields } from './network-redact.js';
5
5
  import { watchStreamedBody } from './network-stream.js';
6
6
  import { requireCapturedMethod } from '../util/captured-method.js';
7
7
  // Redaction moved to its own cohesive module (network.ts is at its line cap); re-exported so callers
8
8
  // and the existing test suite keep importing it from here.
9
- export { redactUrl };
9
+ export { redactUrl, netUrlFields };
10
10
  /** The byte size of a binary frame (ArrayBuffer / Blob / typed-array view), or undefined if unknown. */
11
11
  function binaryFrameBytes(data) {
12
12
  if (data instanceof ArrayBuffer)
@@ -244,10 +244,17 @@ export function installNetwork(emit, opts = {}) {
244
244
  const id = nextId();
245
245
  const start = performance.now();
246
246
  const method = methodOf(input, init);
247
- const url = redactUrl(rawUrl);
247
+ const urlFields = netUrlFields(rawUrl);
248
+ const url = urlFields.url;
248
249
  const initiatorStack = initiatorFrame();
249
250
  const initiatorFields = initiatorStack === undefined ? {} : { initiatorStack };
250
- emit(EventType.NET_PENDING, { id, method, url, initiator: 'fetch', ...initiatorFields });
251
+ emit(EventType.NET_PENDING, {
252
+ id,
253
+ method,
254
+ ...urlFields,
255
+ initiator: 'fetch',
256
+ ...initiatorFields,
257
+ });
251
258
  try {
252
259
  const res = await callFetch(input, init);
253
260
  // The app's fetch resolves HERE — at headers — like a native fetch. durationMs is measured to
@@ -261,7 +268,7 @@ export function installNetwork(emit, opts = {}) {
261
268
  emit(EventType.NET_REQUEST, {
262
269
  id,
263
270
  method,
264
- url,
271
+ ...urlFields,
265
272
  status: res.status,
266
273
  ok: statusIsOk(res.status),
267
274
  durationMs: Math.round(headersAt - start),
@@ -376,7 +383,7 @@ export function installNetwork(emit, opts = {}) {
376
383
  emit(EventType.NET_PENDING, {
377
384
  id: m.id,
378
385
  method: m.method,
379
- url: m.url,
386
+ ...netUrlFields(m.rawUrl),
380
387
  initiator: 'xhr',
381
388
  ...initiatorFields,
382
389
  });
@@ -405,7 +412,7 @@ export function installNetwork(emit, opts = {}) {
405
412
  emit(EventType.NET_REQUEST, {
406
413
  id: cur.id,
407
414
  method: cur.method,
408
- url: cur.url,
415
+ ...netUrlFields(cur.rawUrl),
409
416
  status: this.status,
410
417
  ok: statusIsOk(this.status),
411
418
  durationMs: Math.round(performance.now() - cur.start),
@@ -432,17 +439,17 @@ export function installNetwork(emit, opts = {}) {
432
439
  window.EventSource = class extends origEventSource {
433
440
  constructor(u, init) {
434
441
  super(u, init);
435
- const url = redactUrl(String(u));
442
+ const urlFields = netUrlFields(String(u));
436
443
  emit(EventType.NET_STREAM, {
437
444
  transport: StreamTransport.SSE,
438
445
  direction: StreamDirection.OPEN,
439
- url,
446
+ ...urlFields,
440
447
  });
441
448
  this.addEventListener('message', (ev) => {
442
449
  emit(EventType.NET_STREAM, {
443
450
  transport: StreamTransport.SSE,
444
451
  direction: StreamDirection.IN,
445
- url,
452
+ ...urlFields,
446
453
  ...frameFields(ev.data, captureBodies),
447
454
  });
448
455
  });
@@ -459,17 +466,17 @@ export function installNetwork(emit, opts = {}) {
459
466
  this.#isBridge = isBridgeSocket(String(u));
460
467
  if (this.#isBridge)
461
468
  return;
462
- const url = redactUrl(String(u));
469
+ const urlFields = netUrlFields(String(u));
463
470
  emit(EventType.NET_STREAM, {
464
471
  transport: StreamTransport.WS,
465
472
  direction: StreamDirection.OPEN,
466
- url,
473
+ ...urlFields,
467
474
  });
468
475
  this.addEventListener('message', (ev) => {
469
476
  emit(EventType.NET_STREAM, {
470
477
  transport: StreamTransport.WS,
471
478
  direction: StreamDirection.IN,
472
- url,
479
+ ...urlFields,
473
480
  ...frameFields(ev.data, captureBodies),
474
481
  });
475
482
  });
@@ -486,7 +493,7 @@ export function installNetwork(emit, opts = {}) {
486
493
  emit(EventType.NET_STREAM, {
487
494
  transport: StreamTransport.WS,
488
495
  direction: StreamDirection.OUT,
489
- url: redactUrl(this.url),
496
+ ...netUrlFields(this.url),
490
497
  ...frameFields(data, captureBodies),
491
498
  });
492
499
  super.send(data);
@@ -504,7 +511,7 @@ export function installNetwork(emit, opts = {}) {
504
511
  if (navProto !== null && origBeacon !== undefined) {
505
512
  patchedBeacon = function (url, data) {
506
513
  const id = nextId();
507
- const redacted = redactUrl(String(url));
514
+ const urlFields = netUrlFields(String(url));
508
515
  const initiatorStack = initiatorFrame();
509
516
  const sent = origBeacon.call(this, url, data);
510
517
  // sendBeacon returns whether the payload was QUEUED, not an HTTP result — the response never
@@ -513,7 +520,7 @@ export function installNetwork(emit, opts = {}) {
513
520
  emit(EventType.NET_REQUEST, {
514
521
  id,
515
522
  method: 'POST',
516
- url: redacted,
523
+ ...urlFields,
517
524
  status: 0,
518
525
  ok: sent,
519
526
  queued: sent,
@@ -558,7 +565,7 @@ export function installNetwork(emit, opts = {}) {
558
565
  emit(EventType.NET_REQUEST, {
559
566
  id: nextId(),
560
567
  method: 'GET',
561
- url: redactUrl(rawUrl),
568
+ ...netUrlFields(rawUrl),
562
569
  durationMs: Math.round(entry.duration),
563
570
  initiator: type,
564
571
  ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
@@ -5,6 +5,8 @@ const THROTTLE_MS = 100;
5
5
  const REVEAL_SELECTOR = '[data-reticle-reveal], [data-reveal], section';
6
6
  /** Observe scroll position + reveal-on-scroll for modern scroll-reactive UIs. */
7
7
  export function installScroll(emit) {
8
+ const ac = new AbortController();
9
+ const { signal } = ac;
8
10
  let lastEmit = 0;
9
11
  let lastY = 0;
10
12
  let trailingTimer;
@@ -39,7 +41,7 @@ export function installScroll(emit) {
39
41
  }, THROTTLE_MS - elapsed);
40
42
  }
41
43
  };
42
- window.addEventListener('scroll', onScroll, { passive: true });
44
+ window.addEventListener('scroll', onScroll, { passive: true, signal });
43
45
  let io;
44
46
  if ('function' === typeof IntersectionObserver) {
45
47
  io = new IntersectionObserver((entries) => {
@@ -60,7 +62,7 @@ export function installScroll(emit) {
60
62
  observer.observe(el);
61
63
  }
62
64
  return () => {
63
- window.removeEventListener('scroll', onScroll);
65
+ ac.abort();
64
66
  if (trailingTimer !== undefined)
65
67
  nativeClearTimeout(trailingTimer);
66
68
  io?.disconnect();
@@ -65,7 +65,7 @@ export function readStorage(area) {
65
65
  return readArea(safeArea(() => window.localStorage));
66
66
  if (area === StorageArea.SESSION)
67
67
  return readArea(safeArea(() => window.sessionStorage));
68
- if ('cookies' === area)
68
+ if (StorageArea.COOKIE === area)
69
69
  return readCookies();
70
70
  return {
71
71
  local: readArea(safeArea(() => window.localStorage)),
@@ -9,28 +9,14 @@
9
9
  */
10
10
  /**
11
11
  * Brand marks, inlined. The SDK is bundled into someone else's page: it cannot read
12
- * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the two
13
- * artwork files live here as strings (same precedent as SEND_ICON in presenter-controls.ts).
14
- * Both are minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and paint
12
+ * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the mark
13
+ * lives here as a string (same precedent as SEND_ICON in presenter-controls.ts).
14
+ * Minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and painted
15
15
  * with `currentColor` so the mark tracks --reticle-fg instead of hardcoding the export's #FAFAFA.
16
16
  */
17
- /** Accessible name of the HUD - the wordmark is artwork, this is what a screen reader reads. */
17
+ /** Accessible name of the HUD. */
18
18
  export declare const BRAND_NAME = "Reticle";
19
19
  /** Mark alone (shown in the FAB and compact toolbar). */
20
20
  export declare const MARK_SVG: string;
21
- /** FAB-sized mark for the collapsed circular control. */
22
- export declare const FAB_MARK_SVG: string;
23
- /** Mark + "reticle" lettering (legacy wordmark artwork). */
24
- export declare const WORDMARK_SVG: string;
25
- /** Compact text label beside the mark in the expanded toolbar. */
26
- export declare const BRAND_MINI_CLASS = "reticle-brand-mini";
27
- /** Toolbar brand: mark + Reticle word (always shown together when expanded). */
28
- export declare const TOOLBAR_BRAND_HTML: string;
29
- /**
30
- * Header brand block. Both marks are always in the DOM and CSS picks one off the collapsed
31
- * attribute, so there is no JS state to keep in sync. The artwork is decorative (aria-hidden);
32
- * the visually-hidden name is what keeps the HUD identifiable to a screen reader in BOTH states.
33
- */
34
- export declare const BRAND_HTML: string;
35
21
  /** Collapsed FAB control - Reticle mark only. */
36
22
  export declare const FAB_TOGGLE_HTML: string;
@@ -9,30 +9,18 @@
9
9
  */
10
10
  /**
11
11
  * Brand marks, inlined. The SDK is bundled into someone else's page: it cannot read
12
- * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the two
13
- * artwork files live here as strings (same precedent as SEND_ICON in presenter-controls.ts).
14
- * Both are minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and paint
12
+ * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the mark
13
+ * lives here as a string (same precedent as SEND_ICON in presenter-controls.ts).
14
+ * Minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and painted
15
15
  * with `currentColor` so the mark tracks --reticle-fg instead of hardcoding the export's #FAFAFA.
16
16
  */
17
- /** Accessible name of the HUD - the wordmark is artwork, this is what a screen reader reads. */
17
+ /** Accessible name of the HUD. */
18
18
  export const BRAND_NAME = 'Reticle';
19
- /** The two glyph paths of the mark, shared by both marks - the wordmark just offsets them by (1,1). */
19
+ /** The two glyph paths of the mark. */
20
20
  const MARK_PATHS = `<path d="M92.82 92.16C93.07 93.4 91.77 94.4 90.63 93.83C89.62 93.33 88.62 92.82 87.63 92.3C71.14 83.78 54.79 72.27 39.61 57.96C39.09 57.49 38.57 56.98 38.06 56.49C22.45 41.52 9.78 25.14 .33 8.52C0.29 8.45 .25 8.38 .2 8.31C-0.44 7.2 .51 5.84 1.79 6.05L24.07 9.71C43.06 12.83 60.63 11.75 75.55 6.53L93.95 .09C95.15-0.33 96.31 .78 95.93 1.98L90.1 20.49C85.37 35.48 84.9 52.97 88.69 71.73L92.83 92.16H92.82Z"/><path d="M58.72 89.23L6.15 92.8C5.24 92.86 4.48 92.13 4.51 91.22L6.56 30.39C6.61 28.91 8.51 28.36 9.35 29.57C16.23 39.43 24.98 52.35 33.01 61.26C41.05 70.17 50.6 78.72 59.62 86.56C60.65 87.45 60.08 89.13 58.72 89.23Z"/>`;
21
21
  /** Mark alone (shown in the FAB and compact toolbar). */
22
22
  export const MARK_SVG = `<svg class="reticle-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
23
23
  /** FAB-sized mark for the collapsed circular control. */
24
- export const FAB_MARK_SVG = `<svg class="reticle-fab-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
25
- /** Mark + "reticle" lettering (legacy wordmark artwork). */
26
- export const WORDMARK_SVG = `<svg class="reticle-wordmark" viewBox="0 0 347 96" fill="currentColor" aria-hidden="true"><g transform="translate(1 1)">${MARK_PATHS}</g><path d="M116.43 78V27.09H136.51C140.35 27.09 143.64 27.78 146.35 29.15C149.09 30.51 151.17 32.44 152.59 34.95C154.04 37.43 154.76 40.36 154.76 43.72C154.76 47.1 154.03 50.01 152.57 52.45C151.11 54.87 149 56.72 146.23 58.01C143.48 59.31 140.15 59.95 136.24 59.95H122.79V51.3H134.5C136.55 51.3 138.26 51.02 139.62 50.46C140.98 49.89 141.99 49.05 142.65 47.92C143.33 46.8 143.67 45.39 143.67 43.72C143.67 42.03 143.33 40.61 142.65 39.45C141.99 38.29 140.97 37.41 139.59 36.81C138.23 36.2 136.52 35.89 134.45 35.89H127.19V78H116.43ZM143.92 54.83L156.57 78H144.69L132.31 54.83H143.92ZM178.95 78.75C175.02 78.75 171.64 77.95 168.81 76.36C165.99 74.75 163.82 72.48 162.29 69.55C160.77 66.6 160.01 63.11 160.01 59.08C160.01 55.16 160.77 51.71 162.29 48.74C163.82 45.78 165.97 43.46 168.73 41.81C171.52 40.15 174.78 39.32 178.53 39.32C181.05 39.32 183.39 39.73 185.56 40.54C187.75 41.33 189.65 42.54 191.28 44.14C192.92 45.75 194.2 47.77 195.11 50.21C196.02 52.63 196.47 55.46 196.47 58.71V61.62H164.23V55.06H186.51C186.51 53.53 186.17 52.18 185.51 51C184.85 49.83 183.93 48.91 182.75 48.25C181.59 47.57 180.24 47.23 178.7 47.23C177.09 47.23 175.67 47.6 174.43 48.34C173.2 49.07 172.24 50.06 171.54 51.3C170.85 52.53 170.49 53.9 170.47 55.4V61.64C170.47 63.53 170.82 65.17 171.52 66.54C172.23 67.92 173.23 68.98 174.52 69.72C175.82 70.47 177.35 70.84 179.12 70.84C180.3 70.84 181.38 70.68 182.35 70.34C183.33 70.01 184.17 69.52 184.87 68.85C185.56 68.19 186.09 67.38 186.46 66.42L196.25 67.06C195.75 69.42 194.73 71.47 193.19 73.23C191.67 74.97 189.7 76.33 187.28 77.3C184.87 78.27 182.1 78.75 178.95 78.75ZM223.69 39.82V47.77H200.7V39.82H223.69ZM205.92 30.67H216.51V66.27C216.51 67.24 216.66 68.01 216.96 68.55C217.26 69.08 217.67 69.46 218.2 69.67C218.75 69.89 219.38 70 220.09 70C220.59 70 221.08 69.95 221.58 69.87C222.08 69.77 222.46 69.7 222.72 69.65L224.39 77.53C223.86 77.69 223.11 77.88 222.15 78.1C221.19 78.33 220.02 78.47 218.65 78.52C216.1 78.62 213.86 78.28 211.94 77.5C210.03 76.72 208.55 75.51 207.49 73.87C206.43 72.23 205.9 70.16 205.92 67.66V30.67ZM230.59 78V39.82H241.18V78H230.59ZM235.91 34.9C234.34 34.9 232.99 34.37 231.86 33.33C230.75 32.27 230.19 31 230.19 29.53C230.19 28.07 230.75 26.82 231.86 25.77C232.99 24.71 234.34 24.18 235.91 24.18C237.49 24.18 238.83 24.71 239.94 25.77C241.07 26.82 241.63 28.07 241.63 29.53C241.63 31 241.07 32.27 239.94 33.33C238.83 34.37 237.49 34.9 235.91 34.9ZM266.92 78.75C263 78.75 259.64 77.92 256.82 76.26C254.02 74.59 251.87 72.27 250.36 69.3C248.87 66.33 248.12 62.92 248.12 59.06C248.12 55.15 248.88 51.72 250.38 48.77C251.91 45.8 254.07 43.49 256.87 41.83C259.67 40.16 263 39.32 266.87 39.32C270.2 39.32 273.11 39.93 275.62 41.14C278.12 42.35 280.1 44.04 281.56 46.23C283.01 48.42 283.82 50.99 283.97 53.94H273.97C273.69 52.03 272.95 50.5 271.74 49.34C270.54 48.16 268.98 47.57 267.04 47.57C265.4 47.57 263.96 48.02 262.74 48.92C261.53 49.79 260.58 51.08 259.9 52.77C259.23 54.46 258.89 56.51 258.89 58.91C258.89 61.35 259.22 63.42 259.88 65.12C260.56 66.83 261.51 68.13 262.74 69.03C263.96 69.92 265.4 70.37 267.04 70.37C268.25 70.37 269.33 70.12 270.3 69.62C271.27 69.13 272.08 68.4 272.71 67.46C273.35 66.5 273.78 65.35 273.97 64.01H283.97C283.8 66.92 283.01 69.49 281.58 71.71C280.17 73.92 278.23 75.64 275.74 76.88C273.25 78.12 270.31 78.75 266.92 78.75ZM301.34 27.09V78H290.75V27.09H301.34ZM327.22 78.75C323.29 78.75 319.91 77.95 317.08 76.36C314.26 74.75 312.09 72.48 310.57 69.55C309.04 66.6 308.28 63.11 308.28 59.08C308.28 55.16 309.04 51.71 310.57 48.74C312.09 45.78 314.24 43.46 317 41.81C319.79 40.15 323.05 39.32 326.8 39.32C329.32 39.32 331.66 39.73 333.83 40.54C336.02 41.33 337.93 42.54 339.55 44.14C341.19 45.75 342.47 47.77 343.38 50.21C344.29 52.63 344.75 55.46 344.75 58.71V61.62H312.5V55.06H334.78C334.78 53.53 334.45 52.18 333.78 51C333.12 49.83 332.2 48.91 331.02 48.25C329.86 47.57 328.51 47.23 326.97 47.23C325.36 47.23 323.94 47.6 322.7 48.34C321.47 49.07 320.51 50.06 319.81 51.3C319.12 52.53 318.76 53.9 318.74 55.4V61.64C318.74 63.53 319.09 65.17 319.79 66.54C320.5 67.92 321.5 68.98 322.8 69.72C324.09 70.47 325.62 70.84 327.39 70.84C328.57 70.84 329.65 70.68 330.63 70.34C331.6 70.01 332.44 69.52 333.14 68.85C333.83 68.19 334.36 67.38 334.73 66.42L344.52 67.06C344.02 69.42 343.01 71.47 341.46 73.23C339.94 74.97 337.97 76.33 335.55 77.3C333.15 78.27 330.37 78.75 327.22 78.75Z"/></svg>`;
27
- /** Compact text label beside the mark in the expanded toolbar. */
28
- export const BRAND_MINI_CLASS = 'reticle-brand-mini';
29
- /** Toolbar brand: mark + Reticle word (always shown together when expanded). */
30
- export const TOOLBAR_BRAND_HTML = `<span class="reticle-brand reticle-brand--toolbar">${MARK_SVG}<span class="${BRAND_MINI_CLASS}">${BRAND_NAME}</span><span class="reticle-brand-sr">${BRAND_NAME}</span></span>`;
31
- /**
32
- * Header brand block. Both marks are always in the DOM and CSS picks one off the collapsed
33
- * attribute, so there is no JS state to keep in sync. The artwork is decorative (aria-hidden);
34
- * the visually-hidden name is what keeps the HUD identifiable to a screen reader in BOTH states.
35
- */
36
- export const BRAND_HTML = `<span class="reticle-brand">${MARK_SVG}${WORDMARK_SVG}<span class="${BRAND_MINI_CLASS}">${BRAND_NAME}</span><span class="reticle-brand-sr">${BRAND_NAME}</span></span>`;
24
+ const FAB_MARK_SVG = `<svg class="reticle-fab-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
37
25
  /** Collapsed FAB control - Reticle mark only. */
38
26
  export const FAB_TOGGLE_HTML = `<button type="button" class="reticle-fab" data-reticle-fab aria-label="Start ${BRAND_NAME}" aria-expanded="false">${FAB_MARK_SVG}<span class="reticle-fab-pulse" aria-hidden="true"></span><span data-reticle-mark-count class="reticle-fab-badge" hidden>0</span></button>`;
@@ -104,6 +104,15 @@ export declare const GLOW_FADE_MS = 250;
104
104
  export declare const GLOW_ON = "1";
105
105
  export declare const GLOW_OFF = "0";
106
106
  export declare const DATA_ON = "data-on";
107
+ /** The overlay root's state attribute, which the shell styles resolve colour against. */
108
+ export declare const STATE_ATTR = "data-reticle-state";
109
+ /**
110
+ * Not a SessionState: there is no session. It is the absence of one, said out loud.
111
+ *
112
+ * Kept off `SessionState` deliberately — that enum crosses the wire, and this state exists only
113
+ * because nothing crossed the wire.
114
+ */
115
+ export declare const UNREACHABLE_STATE = "unreachable";
107
116
  /** Overlay-root attribute toggled when the HUD is collapsed to the FAB. */
108
117
  export declare const MIN_ATTR = "data-reticle-min";
109
118
  /** Overlay-root attribute toggled when the agent chat panel is open. */
@@ -126,10 +135,6 @@ export declare const Placement: {
126
135
  readonly ABOVE: "above";
127
136
  readonly BELOW: "below";
128
137
  };
129
- export declare const DockAlign: {
130
- readonly START: "start";
131
- readonly END: "end";
132
- };
133
138
  /** Show/hide annotation markers. */
134
139
  export declare const MARKERS_BTN_ATTR = "data-reticle-markers-btn";
135
140
  /** Clear every annotation on the page. */
@@ -177,10 +182,8 @@ export declare const LOG_TIMESTAMPS_ATTR = "data-reticle-log-ts";
177
182
  /** When 1, the chat panel uses a narrower width. */
178
183
  /** When 1, HUD motion is reduced for accessibility. */
179
184
  export declare const REDUCE_MOTION_ATTR = "data-reticle-reduce-motion";
180
- /** Presenter settings card version label (browser package). */
181
- export declare const PRESENTER_UI_VERSION = "2.7.0";
182
185
  /** Docs opened from the settings footer. */
183
- export declare const MCP_DOCS_URL = "https://github.com/reticlehq/reticle/blob/main/docs/getting-started.md";
186
+ export declare const MCP_DOCS_URL: string;
184
187
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */
185
188
  export declare const HUD_DRAGGED_ATTR = "data-dragged";
186
189
  /** CSS custom properties written when the HUD is dragged. */
@@ -191,5 +194,5 @@ export declare const HUD_DRAG_THRESHOLD_PX = 4;
191
194
  /** Minimum gap between a dragged HUD and the viewport edge. */
192
195
  export declare const HUD_DOCK_MARGIN_PX = 8;
193
196
  /** Interactive nodes inside the drag handle that must not start a drag (not the FAB itself). */
194
- export declare const HUD_DRAG_IGNORE_SEL = "[data-reticle-pause], [data-reticle-annotate-btn], [data-reticle-markers-btn], [data-reticle-clear-marks], [data-reticle-end], [data-reticle-min-btn], [data-reticle-settings-btn], [data-reticle-settings-panel], [data-reticle-report-btn], [data-reticle-report-panel], [data-reticle-chat-panel], [data-reticle-chat-toggle], [data-reticle-workspace-btn], [data-reticle-workspace-menu], [data-reticle-copy], [data-reticle-export], [data-reticle-send], input, textarea, select, a, .reticle-head-ctl, [data-reticle-tally], .reticle-maxhint";
197
+ export declare const HUD_DRAG_IGNORE_SEL: string;
195
198
  export declare const THROTTLED_ATTR = "data-reticle-throttled";
@@ -72,6 +72,15 @@ export const GLOW_FADE_MS = 250;
72
72
  export const GLOW_ON = '1';
73
73
  export const GLOW_OFF = '0';
74
74
  export const DATA_ON = 'data-on';
75
+ /** The overlay root's state attribute, which the shell styles resolve colour against. */
76
+ export const STATE_ATTR = 'data-reticle-state';
77
+ /**
78
+ * Not a SessionState: there is no session. It is the absence of one, said out loud.
79
+ *
80
+ * Kept off `SessionState` deliberately — that enum crosses the wire, and this state exists only
81
+ * because nothing crossed the wire.
82
+ */
83
+ export const UNREACHABLE_STATE = 'unreachable';
75
84
  /** Overlay-root attribute toggled when the HUD is collapsed to the FAB. */
76
85
  export const MIN_ATTR = 'data-reticle-min';
77
86
  /** Overlay-root attribute toggled when the agent chat panel is open. */
@@ -91,7 +100,6 @@ export const SETTINGS_PLACEMENT_ATTR = 'data-reticle-settings-placement';
91
100
  /** Horizontal alignment of dock-anchored panels (start = left, end = right). */
92
101
  export const DOCK_ALIGN_ATTR = 'data-reticle-dock-align';
93
102
  export const Placement = { ABOVE: 'above', BELOW: 'below' };
94
- export const DockAlign = { START: 'start', END: 'end' };
95
103
  /** Show/hide annotation markers. */
96
104
  export const MARKERS_BTN_ATTR = 'data-reticle-markers-btn';
97
105
  /** Clear every annotation on the page. */
@@ -139,8 +147,6 @@ export const LOG_TIMESTAMPS_ATTR = 'data-reticle-log-ts';
139
147
  /** When 1, the chat panel uses a narrower width. */
140
148
  /** When 1, HUD motion is reduced for accessibility. */
141
149
  export const REDUCE_MOTION_ATTR = 'data-reticle-reduce-motion';
142
- /** Presenter settings card version label (browser package). */
143
- export const PRESENTER_UI_VERSION = '2.7.0';
144
150
  /** Docs opened from the settings footer. */
145
151
  export const MCP_DOCS_URL = 'https://github.com/reticlehq/reticle/blob/main/docs/getting-started.md';
146
152
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */
@@ -1,5 +1,4 @@
1
1
  import { HumanControlKind, PresenterTone, SessionState } from '@reticlehq/core';
2
- export declare const PAUSED_BADGE_LABEL = "PAUSED";
3
2
  /** Border fade-out delay after a session ends (native timer; presenter-only tunable). */
4
3
  export declare const ENDED_FADE_MS = 4000;
5
4
  /** Payload the panel hands to its host when the human drives a control. */
@@ -12,8 +11,6 @@ export type ControlHandler = (intent: ControlIntent) => void;
12
11
  export declare const CONTROLS_CSS: string;
13
12
  /** Icon toolbar: pause, chat, markers, end, clear - copy/export appear after a run ends. */
14
13
  export declare const CONTROLS_TOOLBAR_HTML: string;
15
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
16
- export declare const CONTROLS_HEAD_HTML: string;
17
14
  /** Banner markup (between head and log, hidden unless ended). */
18
15
  export declare const CONTROLS_BANNER_HTML = "<div data-reticle-banner class=\"reticle-banner\">Session ended</div>";
19
16
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -26,7 +26,6 @@ const INPUT_PLACEHOLDER = 'Tell the agent something…';
26
26
  /** Accessible name for the composer (a placeholder is not an accessible name). */
27
27
  const INPUT_ARIA_LABEL = 'Message to the agent';
28
28
  const PAUSED_BADGE_TEXT = 'PAUSED';
29
- export const PAUSED_BADGE_LABEL = PAUSED_BADGE_TEXT;
30
29
  const ENDED_BANNER_TEXT = 'Session ended';
31
30
  const COPY_LABEL = 'Copy run';
32
31
  const EXPORT_LABEL = 'Export';
@@ -177,8 +176,6 @@ export const CONTROLS_TOOLBAR_HTML = [
177
176
  `<button type="button" data-reticle-export class="reticle-tb-btn reticle-tb-btn--export" title="${EXPORT_LABEL}" aria-label="${EXPORT_LABEL}" hidden>${hiIconHtml(PresenterIcon.DOWNLOAD, TB)}</button>`,
178
177
  '<span data-reticle-export-msg class="reticle-export-msg" aria-live="polite"></span>',
179
178
  ].join('');
180
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
181
- export const CONTROLS_HEAD_HTML = CONTROLS_TOOLBAR_HTML;
182
179
  /** Banner markup (between head and log, hidden unless ended). */
183
180
  export const CONTROLS_BANNER_HTML = `<div data-reticle-banner class="reticle-banner">${ENDED_BANNER_TEXT}</div>`;
184
181
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -14,4 +14,4 @@ export declare const HUD_LOG_WELL_CLASS = "reticle-hud-log-well";
14
14
  * the panel read as a container rather than as one card. It also grew and shrank with its content,
15
15
  * so the panel resized on every row; the height is fixed now and the feed scrolls inside it.
16
16
  */
17
- export declare const HUD_LOG_WELL_CSS = "\n.reticle-hud-log-well{\n /* A settled height, but still allowed to shrink: pinned with flex:none it pushed the composer\n off the bottom of the panel the moment the rest of the content grew. */\n /* A flex column, so the feed inside actually FILLS the height: as a plain block the log was only\n as tall as its rows, which left the empty-state line floating near the top of an otherwise\n empty panel instead of centred in it. */\n position:relative;display:flex;flex-direction:column;\n flex:1 1 auto;height:210px;min-height:96px;max-height:210px;overflow:hidden;\n contain:layout style paint;\n margin:0 6px;border-radius:12px;background:transparent;}\n.reticle-hud-log-well > *{position:relative;z-index:1;}";
17
+ export declare const HUD_LOG_WELL_CSS: string;
@@ -32,7 +32,19 @@ export declare const REPORT_TEXT: {
32
32
  readonly TITLE: "Impact";
33
33
  readonly PROJECT: "This project";
34
34
  readonly GLOBAL: "Everything on this machine";
35
- readonly HERO_DEFECTS: "defects caught before you saw them";
35
+ /**
36
+ * What `counts.failed` actually is: a verdict whose declared consequence did not hold.
37
+ *
38
+ * It used to read "defects caught before you saw them", which overclaims in the one direction a
39
+ * verification tool must never overclaim. A failed verdict is not proof of a defect in the app —
40
+ * it is equally the shape of an assertion that was wrong. Measured in the field: an agent
41
+ * asserted a clean console on an app with ordinary dev-mode logging, the verdict went red, and
42
+ * the panel reported a defect nobody had found.
43
+ *
44
+ * The honest word is what Reticle DID: it refused to pass them. That is true of the assertion
45
+ * error and the real bug alike, and it still reads as the tool having done its job.
46
+ */
47
+ readonly HERO_DEFECTS: "checks Reticle refused to pass";
36
48
  readonly VERDICTS: "Verdicts";
37
49
  readonly PASSED: "passed";
38
50
  readonly FAILED: "failed";
@@ -48,6 +60,23 @@ export declare const REPORT_TEXT: {
48
60
  readonly SAVED_MINUTES: "Time saved";
49
61
  readonly ESTIMATE_TAG: "estimate";
50
62
  readonly CHART: "Verdicts, last 30 days";
63
+ readonly DEFECTS: "What broke";
64
+ readonly DEFECTS_MORE: "Manage all of them on the dashboard";
65
+ /**
66
+ * What an UNLINKED user is told, and the only place the product tells them.
67
+ *
68
+ * The dashboard link renders only when `dashboardUrl` exists, which means only once a repo is
69
+ * already linked — so the person most likely to want one, watching this record climb on their own
70
+ * machine, was never told it existed. The single mention anywhere else is a daemon log line.
71
+ *
72
+ * Stated as a FACT about where the record lives, not as a pitch. It sits at the foot of a panel
73
+ * somebody opened on purpose, so it informs rather than interrupts, and it appears only once
74
+ * there is a verdict worth keeping — an offer to preserve nothing is just an advert.
75
+ */
76
+ readonly LOCAL_ONLY: "This record stops at this machine.";
77
+ readonly LOCAL_ONLY_ACTION: "reticle login";
78
+ readonly LOCAL_ONLY_TAIL: "keeps it, and lets a team see it.";
79
+ readonly DEFECTS_NONE: "Nothing has failed a declared consequence yet.";
51
80
  readonly EMPTY: "Nothing recorded yet. Drive the app once and this fills in.";
52
81
  readonly SHARE: "Share";
53
82
  readonly COPY: "Copy";
@@ -32,7 +32,19 @@ export const REPORT_TEXT = {
32
32
  TITLE: 'Impact',
33
33
  PROJECT: 'This project',
34
34
  GLOBAL: 'Everything on this machine',
35
- HERO_DEFECTS: 'defects caught before you saw them',
35
+ /**
36
+ * What `counts.failed` actually is: a verdict whose declared consequence did not hold.
37
+ *
38
+ * It used to read "defects caught before you saw them", which overclaims in the one direction a
39
+ * verification tool must never overclaim. A failed verdict is not proof of a defect in the app —
40
+ * it is equally the shape of an assertion that was wrong. Measured in the field: an agent
41
+ * asserted a clean console on an app with ordinary dev-mode logging, the verdict went red, and
42
+ * the panel reported a defect nobody had found.
43
+ *
44
+ * The honest word is what Reticle DID: it refused to pass them. That is true of the assertion
45
+ * error and the real bug alike, and it still reads as the tool having done its job.
46
+ */
47
+ HERO_DEFECTS: 'checks Reticle refused to pass',
36
48
  VERDICTS: 'Verdicts',
37
49
  PASSED: 'passed',
38
50
  FAILED: 'failed',
@@ -48,6 +60,23 @@ export const REPORT_TEXT = {
48
60
  SAVED_MINUTES: 'Time saved',
49
61
  ESTIMATE_TAG: 'estimate',
50
62
  CHART: 'Verdicts, last 30 days',
63
+ DEFECTS: 'What broke',
64
+ DEFECTS_MORE: 'Manage all of them on the dashboard',
65
+ /**
66
+ * What an UNLINKED user is told, and the only place the product tells them.
67
+ *
68
+ * The dashboard link renders only when `dashboardUrl` exists, which means only once a repo is
69
+ * already linked — so the person most likely to want one, watching this record climb on their own
70
+ * machine, was never told it existed. The single mention anywhere else is a daemon log line.
71
+ *
72
+ * Stated as a FACT about where the record lives, not as a pitch. It sits at the foot of a panel
73
+ * somebody opened on purpose, so it informs rather than interrupts, and it appears only once
74
+ * there is a verdict worth keeping — an offer to preserve nothing is just an advert.
75
+ */
76
+ LOCAL_ONLY: 'This record stops at this machine.',
77
+ LOCAL_ONLY_ACTION: 'reticle login',
78
+ LOCAL_ONLY_TAIL: 'keeps it, and lets a team see it.',
79
+ DEFECTS_NONE: 'Nothing has failed a declared consequence yet.',
51
80
  EMPTY: 'Nothing recorded yet. Drive the app once and this fills in.',
52
81
  SHARE: 'Share',
53
82
  COPY: 'Copy',
@@ -88,7 +117,7 @@ export function buildShareText(scope, projectName) {
88
117
  const where = projectName !== undefined && projectName.length > 0 ? ` on ${projectName}` : '';
89
118
  const lines = [
90
119
  `My agent verified its own work ${String(c.verdicts)} times${where}.`,
91
- `${String(c.failed)} defects caught before I looked at any of them.`,
120
+ `${String(c.failed)} checks it refused to pass before I looked at any of them.`,
92
121
  ];
93
122
  if (c.unknown > 0) {
94
123
  lines.push(`${String(c.unknown)} came back "unknown" - it drove the app and could not tell. That is the number I watch.`);
@@ -73,6 +73,30 @@ export const REPORT_CSS = `
73
73
  [${REPORT_PANEL_ATTR}] .reticle-report-basis{
74
74
  display:block;margin-top:2px;color:var(--reticle-c-active);opacity:.8;
75
75
  font-size:8.5px;letter-spacing:.06em;text-transform:uppercase;cursor:help;}
76
+ [${REPORT_PANEL_ATTR}] .reticle-report-defects-wrap{margin-top:14px;}
77
+ [${REPORT_PANEL_ATTR}] .reticle-report-defects{
78
+ display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none;}
79
+ /* A left rule rather than a card per row: ten cards in a 320px panel is a wall, and the rule keeps
80
+ the list scannable as a list — which is what a person is doing when they open this. */
81
+ [${REPORT_PANEL_ATTR}] .reticle-report-defect{
82
+ display:flex;flex-direction:column;gap:1px;padding:4px 0 4px 8px;
83
+ border-left:2px solid color-mix(in srgb,#f87171 70%,transparent);}
84
+ [${REPORT_PANEL_ATTR}] .reticle-report-defect-title{
85
+ font-size:11px;line-height:1.35;overflow-wrap:anywhere;}
86
+ [${REPORT_PANEL_ATTR}] .reticle-report-defect-detail{
87
+ color:var(--reticle-faint);font-size:10px;line-height:1.35;overflow-wrap:anywhere;}
88
+ [${REPORT_PANEL_ATTR}] .reticle-report-defect-source{
89
+ color:var(--reticle-faint);font-size:9.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
90
+ overflow-wrap:anywhere;}
91
+ [${REPORT_PANEL_ATTR}] .reticle-report-defects-more{
92
+ display:inline-block;margin-top:8px;font-size:10px;color:var(--reticle-c-active);
93
+ text-decoration:none;}
94
+ [${REPORT_PANEL_ATTR}] .reticle-report-defects-more:hover{text-decoration:underline;}
95
+ [${REPORT_PANEL_ATTR}] .reticle-report-local-only{
96
+ margin:14px 0 0;padding-top:10px;border-top:1px solid var(--reticle-line);
97
+ color:var(--reticle-faint);font-size:10px;line-height:1.45;}
98
+ [${REPORT_PANEL_ATTR}] .reticle-report-local-only code{
99
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--reticle-c-active);}
76
100
  [${REPORT_PANEL_ATTR}] .reticle-report-chart-wrap{margin-top:12px;}
77
101
  [${REPORT_PANEL_ATTR}] .reticle-report-section{
78
102
  display:block;margin-bottom:6px;color:var(--reticle-faint);font-size:9.5px;
@@ -1,6 +1,6 @@
1
- import type { ImpactScope, ImpactSnapshot } from '@reticlehq/core';
1
+ import { type ImpactScope, type ImpactSnapshot } from '@reticlehq/core';
2
2
  export declare function reportPanelHtml(): string;
3
- export declare function reportBodyHtml(scope: ImpactScope): string;
3
+ export declare function reportBodyHtml(scope: ImpactScope, dashboardUrl?: string): string;
4
4
  export interface ReportHost {
5
5
  /** Opened from the toolbar and from the chat, so the shell decides what else must close. */
6
6
  onBeforeOpen?: () => void;
@@ -1,3 +1,4 @@
1
+ import { IMPACT_DEFECT_LIMIT } from '@reticlehq/core';
1
2
  import { PresenterIcon, PRESENTER_ICON_SIZE, hiIconHtml } from './presenter-icons.js';
2
3
  import { HUD_SURFACE_CLASS } from './presenter-hud-chrome.js';
3
4
  import { REPORT_PANEL_ATTR, REPORT_ATTR, REPORT_CLOSE_ATTR } from './presenter-config.js';
@@ -67,7 +68,80 @@ function chart(scope) {
67
68
  .join('');
68
69
  return `<div class="reticle-report-chart-wrap"><span class="reticle-report-section">${REPORT_TEXT.CHART}</span><div class="reticle-report-chart">${bars}</div></div>`;
69
70
  }
70
- export function reportBodyHtml(scope) {
71
+ /**
72
+ * Escape text that came from the app under test.
73
+ *
74
+ * A defect title is an element's accessible name or a verdict's failure reason — both of which are
75
+ * ultimately the CONTENT of somebody else's page, and this panel builds its DOM from an HTML string.
76
+ * Everything else the report renders is a number or a date; this is the first app-derived text to
77
+ * reach it, so the escaping arrives with it.
78
+ */
79
+ function esc(value) {
80
+ return value
81
+ .replace(/&/g, '&amp;')
82
+ .replace(/</g, '&lt;')
83
+ .replace(/>/g, '&gt;')
84
+ .replace(/"/g, '&quot;');
85
+ }
86
+ /**
87
+ * The short list of what is currently broken.
88
+ *
89
+ * The hero number already says HOW MANY defects Reticle caught; this says WHICH ONES, which is the
90
+ * difference between a statistic and something a person can act on. Deliberately short: a panel in
91
+ * the corner of somebody's app is not a triage queue, and the link at the bottom is where the queue
92
+ * lives. When the project is not linked to a workspace there is no link and no nagging — the free
93
+ * tool is complete on its own.
94
+ */
95
+ /**
96
+ * Whether a dashboard link is safe to render as a clickable href.
97
+ *
98
+ * The url arrives from `.reticle/cloud.json` — a file in somebody's repository, which means it is
99
+ * INPUT. Escaping the quotes stops it breaking out of the attribute but does nothing about the
100
+ * SCHEME, so `javascript:...` produced a link that ran code inside the developer's own application,
101
+ * from a panel Reticle injected there. Only the two schemes a dashboard can actually live on are
102
+ * allowed; anything else renders no link at all, which is the same state as an unlinked project and
103
+ * therefore already a supported one.
104
+ */
105
+ function isSafeDashboardUrl(raw) {
106
+ try {
107
+ const scheme = new URL(raw).protocol;
108
+ return 'https:' === scheme || 'http:' === scheme;
109
+ }
110
+ catch {
111
+ // Not a url at all. A relative path cannot address a dashboard on another origin, so there is
112
+ // nothing to render and nothing lost by refusing it.
113
+ return false;
114
+ }
115
+ }
116
+ function defects(scope, dashboardUrl) {
117
+ /*
118
+ * Defaulted here as well as in the schema. Zod's default applies when a record is PARSED, and the
119
+ * snapshot reaching this panel is pushed straight from the daemon rather than round-tripped
120
+ * through the schema — so a record written by an older build arrives with no `defects` field at
121
+ * all and used to throw, taking the whole report down with it.
122
+ */
123
+ const list = (scope.defects ?? []).slice(0, IMPACT_DEFECT_LIMIT);
124
+ if (0 === list.length)
125
+ return '';
126
+ const rows = list
127
+ .map((d) => {
128
+ const detail = d.detail === undefined
129
+ ? ''
130
+ : `<span class="reticle-report-defect-detail">${esc(d.detail)}</span>`;
131
+ const source = d.source === undefined
132
+ ? ''
133
+ : `<span class="reticle-report-defect-source">${esc(d.source)}</span>`;
134
+ return `<li class="reticle-report-defect"><span class="reticle-report-defect-title">${esc(d.title)}</span>${detail}${source}</li>`;
135
+ })
136
+ .join('');
137
+ // Only claim there are more when there actually are — `counts.failed` is every defect ever, and
138
+ // this list is the recent tail of it.
139
+ const more = dashboardUrl === undefined || !isSafeDashboardUrl(dashboardUrl)
140
+ ? ''
141
+ : `<a class="reticle-report-defects-more" href="${esc(dashboardUrl)}" target="_blank" rel="noreferrer noopener">${REPORT_TEXT.DEFECTS_MORE}${scope.counts.failed > list.length ? ` (${String(scope.counts.failed)})` : ''}</a>`;
142
+ return `<div class="reticle-report-defects-wrap"><span class="reticle-report-section">${REPORT_TEXT.DEFECTS}</span><ul class="reticle-report-defects">${rows}</ul>${more}</div>`;
143
+ }
144
+ export function reportBodyHtml(scope, dashboardUrl) {
71
145
  const c = scope.counts;
72
146
  if (0 === c.calls)
73
147
  return `<p class="reticle-report-empty">${REPORT_TEXT.EMPTY}</p>`;
@@ -90,7 +164,25 @@ export function reportBodyHtml(scope) {
90
164
  card(compactNumber(scope.savings.tokens.value), REPORT_TEXT.SAVED_TOKENS, scope.savings.tokens.basis),
91
165
  card(compactDuration(scope.savings.minutes.value * 60_000), REPORT_TEXT.SAVED_MINUTES, scope.savings.minutes.basis),
92
166
  ].join('');
93
- return `${streak}${hero}${verdicts}<div class="reticle-report-grid">${cards}</div>${chart(scope)}`;
167
+ return `${streak}${hero}${verdicts}<div class="reticle-report-grid">${cards}</div>${defects(scope, dashboardUrl)}${chart(scope)}${localOnly(scope, dashboardUrl)}`;
168
+ }
169
+ /**
170
+ * The one line an UNLINKED user is shown about the dashboard.
171
+ *
172
+ * Absent `dashboardUrl` IS the unlinked signal — it is only ever set from a repo's cloud.json — so
173
+ * this needs no new plumbing and cannot be wrong about the state it describes.
174
+ *
175
+ * Gated on a VERDICT, not on tool calls. Somebody who has driven the app but proved nothing has not
176
+ * yet received the thing this offers to preserve, and offering to keep nothing is an advert. Past
177
+ * that bar it is a fact about where their record lives, at the foot of a panel they opened on
178
+ * purpose — which is why it does not need to be dismissible.
179
+ */
180
+ function localOnly(scope, dashboardUrl) {
181
+ if (dashboardUrl !== undefined)
182
+ return '';
183
+ if (scope.counts.verdicts <= 0)
184
+ return '';
185
+ return `<p class="reticle-report-local-only">${REPORT_TEXT.LOCAL_ONLY} <code>${REPORT_TEXT.LOCAL_ONLY_ACTION}</code> ${REPORT_TEXT.LOCAL_ONLY_TAIL}</p>`;
94
186
  }
95
187
  /** The report panel controller: scope toggle, live repaint, share + referral actions. */
96
188
  export class PresenterReport {
@@ -194,7 +286,7 @@ export class PresenterReport {
194
286
  this.#body.innerHTML =
195
287
  scope === undefined
196
288
  ? `<p class="reticle-report-empty">${REPORT_TEXT.EMPTY}</p>`
197
- : reportBodyHtml(scope);
289
+ : reportBodyHtml(scope, this.#snapshot?.dashboardUrl);
198
290
  }
199
291
  #openShare(url) {
200
292
  window.open(url, '_blank', 'noopener,noreferrer');