@reticlehq/browser 2.12.0 → 2.13.1

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 (41) hide show
  1. package/dist/actions/actions.js +6 -6
  2. package/dist/connect-options.d.ts +18 -1
  3. package/dist/dom/dom-ignore.d.ts +9 -0
  4. package/dist/dom/dom-ignore.js +32 -3
  5. package/dist/dom/query.js +55 -3
  6. package/dist/dom/snapshot.d.ts +8 -0
  7. package/dist/dom/snapshot.js +71 -5
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +5 -1
  10. package/dist/observers/animation.js +6 -8
  11. package/dist/observers/focus.js +5 -6
  12. package/dist/observers/health.js +6 -9
  13. package/dist/observers/network-redact.d.ts +13 -0
  14. package/dist/observers/network-redact.js +12 -1
  15. package/dist/observers/network.d.ts +2 -2
  16. package/dist/observers/network.js +24 -17
  17. package/dist/observers/scroll.js +4 -2
  18. package/dist/presenter/presenter-config.d.ts +11 -2
  19. package/dist/presenter/presenter-config.js +9 -0
  20. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  21. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  22. package/dist/presenter/presenter-report-copy.js +31 -2
  23. package/dist/presenter/presenter-report-styles.js +24 -0
  24. package/dist/presenter/presenter-report.d.ts +2 -2
  25. package/dist/presenter/presenter-report.js +95 -3
  26. package/dist/presenter/presenter-shell-styles.js +20 -1
  27. package/dist/presenter/presenter-shell.js +0 -1
  28. package/dist/presenter/presenter.d.ts +13 -0
  29. package/dist/presenter/presenter.js +37 -2
  30. package/dist/registry/auto-testids.d.ts +21 -0
  31. package/dist/registry/auto-testids.js +48 -0
  32. package/dist/registry/capabilities.d.ts +28 -1
  33. package/dist/registry/capabilities.js +59 -7
  34. package/dist/registry/store-adapters.d.ts +1 -1
  35. package/dist/registry/store-adapters.js +6 -1
  36. package/dist/registry/stores.d.ts +4 -0
  37. package/dist/registry/stores.js +35 -0
  38. package/dist/reticle.js +8 -0
  39. package/dist/transport/unreachable-message.d.ts +7 -0
  40. package/dist/transport/unreachable-message.js +9 -0
  41. package/package.json +4 -4
@@ -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();
@@ -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. */
@@ -174,7 +183,7 @@ export declare const LOG_TIMESTAMPS_ATTR = "data-reticle-log-ts";
174
183
  /** When 1, HUD motion is reduced for accessibility. */
175
184
  export declare const REDUCE_MOTION_ATTR = "data-reticle-reduce-motion";
176
185
  /** Docs opened from the settings footer. */
177
- 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;
178
187
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */
179
188
  export declare const HUD_DRAGGED_ATTR = "data-dragged";
180
189
  /** CSS custom properties written when the HUD is dragged. */
@@ -185,5 +194,5 @@ export declare const HUD_DRAG_THRESHOLD_PX = 4;
185
194
  /** Minimum gap between a dragged HUD and the viewport edge. */
186
195
  export declare const HUD_DOCK_MARGIN_PX = 8;
187
196
  /** Interactive nodes inside the drag handle that must not start a drag (not the FAB itself). */
188
- 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;
189
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. */
@@ -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');
@@ -306,8 +306,19 @@ export const SHELL_CSS = `
306
306
  padding:2px 6px;border-radius:999px;white-space:nowrap;pointer-events:none;z-index:2;
307
307
  transition:opacity .12s ease;}
308
308
  [${OVERLAY}][${STATE}="paused"] [data-reticle-badge]{display:inline-flex;}
309
+ /**
310
+ * The mode, ON the status row rather than under it.
311
+ *
312
+ * The action text beside it is flex:1, so this claims the right edge of the row and takes its space
313
+ * from the elided action text. Nothing above or below moves: the panel used to grow and shrink by
314
+ * the height of this pill on every single tool call, which is what made one status line look like a
315
+ * second UI appearing and leaving.
316
+ */
309
317
  [${DOCK_ATTR}] .reticle-chip{display:none;flex:none;align-items:center;gap:4px;font-size:8px;font-weight:600;letter-spacing:.06em;
310
- padding:3px 7px;border-radius:999px;text-transform:uppercase;background:rgba(255,255,255,.06);}
318
+ height:16px;padding:0 7px;line-height:1;border-radius:999px;text-transform:uppercase;
319
+ background:rgba(255,255,255,.06);opacity:0;transition:opacity .12s ease;}
320
+ [${DOCK_ATTR}] .reticle-chip[data-mode="reading"],
321
+ [${DOCK_ATTR}] .reticle-chip[data-mode="acting"]{opacity:1;}
311
322
  [${DOCK_ATTR}] .reticle-chip[data-mode="reading"],
312
323
  [${DOCK_ATTR}] .reticle-chip[data-mode="acting"]{display:inline-flex;color:var(--reticle-fg);}
313
324
  [${DOCK_ATTR}] .reticle-tally[hidden]{display:none;}
@@ -344,6 +355,14 @@ export const SHELL_CSS = `
344
355
  0 0 8px color-mix(in srgb,var(--reticle-state) 45%,transparent);}
345
356
  [${CHAT_PANEL}] .reticle-act-strip[data-liveness="idle"] .reticle-act-dot{
346
357
  animation:reticle-idle-pulse 2.4s ease-in-out infinite;}
358
+ /**
359
+ * Unreachable: present, and plainly not working.
360
+ *
361
+ * Muted rather than alarming. Nothing is broken in the user's app — Reticle simply has nobody to
362
+ * talk to — and a dev overlay that shouts about its own plumbing is one the user turns off.
363
+ */
364
+ [${OVERLAY}][${STATE}="unreachable"]{--reticle-state:var(--reticle-faint);}
365
+ [${OVERLAY}][${STATE}="unreachable"] [${CHAT_PANEL}] .reticle-act-dot{animation:none;}
347
366
  [${OVERLAY}][${STATE}="paused"] [${CHAT_PANEL}] .reticle-act-dot,
348
367
  [${OVERLAY}][${STATE}="ended"] [${CHAT_PANEL}] .reticle-act-dot{animation:none;}
349
368
  @keyframes reticle-idle-pulse{0%,100%{opacity:.45;transform:scale(.92)}50%{opacity:1;transform:scale(1)}}
@@ -93,7 +93,6 @@ export class HudShell {
93
93
  <button type="button" ${CHAT_MIN_ATTR} class="reticle-chat-min" title="${CHAT_MIN_LABEL}" aria-label="${CHAT_MIN_LABEL}">${hiIconHtml(PresenterIcon.CARET_DOWN, PRESENTER_ICON_SIZE.TOOLBAR)}</button>
94
94
  ${actStripHtml}
95
95
  <span class="reticle-tally" data-reticle-tally hidden></span>
96
- <span class="reticle-chip" data-reticle-chip></span>
97
96
  ${bannerHtml}
98
97
  <div class="${HUD_LOG_WELL_CLASS}"><div ${logAttr}></div></div>
99
98
  ${flowsHtml}
@@ -41,6 +41,19 @@ export declare class Presenter {
41
41
  * sessionEnd. Idempotent, and a no-op when unmounted or in 'busy' border mode.
42
42
  */
43
43
  sessionStart(): void;
44
+ /**
45
+ * The bridge never answered: show that, rather than showing nothing.
46
+ *
47
+ * An instrumented page with a dead bridge used to be indistinguishable from a page with no
48
+ * Reticle in it — overlay mounted, dock off, nothing on screen. The user cannot tell "I forgot to
49
+ * start the daemon" from "the install did not work", and the commonest cause is the cheapest to
50
+ * say: the port. So the HUD appears, states the URL it tried, and marks itself unreachable so it
51
+ * is never mistaken for a live session.
52
+ *
53
+ * Not an error dialog and not modal: the page is the user's, and a dev overlay that shouts is one
54
+ * they turn off. It is the same capsule they would have had, saying the one thing it knows.
55
+ */
56
+ showUnreachable(url: string, attempts: number): void;
44
57
  /**
45
58
  * Session end: hides the log/HUD and (in 'session' mode) clears the base border. Idempotent; a
46
59
  * no-op without a prior sessionStart or when unmounted.
@@ -1,12 +1,13 @@
1
1
  import { ActionType, ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
2
2
  import { refs } from '../dom/refs.js';
3
+ import { unreachableStripText } from '../transport/unreachable-message.js';
3
4
  import { actionVerb } from './presenter-verbs.js';
4
5
  import { nativeSetTimeout, nativeClearTimeout, nativeNow } from '../timers/native-timers.js';
5
6
  import { LOG_KIND, CHIP_LABEL, DATA_RETICLE_LOG, clampLogMax, formatElapsed, humanDuration, appendLogRow, } from './presenter-log.js';
6
7
  import { PRESENTER_CSS } from './presenter-styles.js';
7
8
  import { HudShell } from './presenter-shell.js';
8
9
  import { parseImpactSnapshot } from './presenter-report-copy.js';
9
- import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, ACT_STRIP, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, THROTTLED_ATTR, LIVENESS_ATTR, MARKERS_BTN_ATTR, CLEAR_MARKS_ATTR, MARK_COUNT_ATTR, } from './presenter-config.js';
10
+ import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, ACT_STRIP, STATE_ATTR, UNREACHABLE_STATE, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, THROTTLED_ATTR, LIVENESS_ATTR, MARKERS_BTN_ATTR, CLEAR_MARKS_ATTR, MARK_COUNT_ATTR, } from './presenter-config.js';
10
11
  import { buildRunState } from './presenter-run-state.js';
11
12
  import { moveCursor, ringAround, spawnRipple, pace } from './presenter-effects.js';
12
13
  import { GlowController } from './presenter-glow.js';
@@ -165,7 +166,11 @@ export class Presenter {
165
166
  document.head.appendChild(style);
166
167
  const root = document.createElement('div');
167
168
  root.setAttribute('data-reticle-overlay', '');
168
- const actStrip = `<div class="reticle-act-strip" data-liveness="idle"><span class="reticle-act-dot" aria-hidden="true"></span><span class="reticle-act">${ACT_STRIP.READY}</span></div>`;
169
+ // The mode rides ON the status row, next to the dot and the elapsed time it already reports.
170
+ // It used to be a sibling BELOW the strip, hidden except while reading or acting — so every
171
+ // single tool call popped a block into the panel and took it away again, which reads as a
172
+ // second UI flashing in rather than as the one status line changing what it says.
173
+ const actStrip = `<div class="reticle-act-strip" data-liveness="idle"><span class="reticle-act-dot" aria-hidden="true"></span><span class="reticle-act">${ACT_STRIP.READY}</span><span class="reticle-chip" data-reticle-chip></span></div>`;
169
174
  root.innerHTML = `
170
175
  ${blockerHtml()}
171
176
  <div data-reticle-glow></div>
@@ -253,6 +258,9 @@ export class Presenter {
253
258
  }
254
259
  if (this.#sessionActive)
255
260
  return;
261
+ // A bridge that answered supersedes whatever the failed connect left on screen.
262
+ if (UNREACHABLE_STATE === this.#root?.getAttribute(STATE_ATTR))
263
+ this.#root.removeAttribute(STATE_ATTR);
256
264
  this.#sessionActive = true;
257
265
  this.#startMs ??= this.#now();
258
266
  this.#endMs = undefined;
@@ -263,6 +271,33 @@ export class Presenter {
263
271
  this.#shell.openChat();
264
272
  }
265
273
  }
274
+ /**
275
+ * The bridge never answered: show that, rather than showing nothing.
276
+ *
277
+ * An instrumented page with a dead bridge used to be indistinguishable from a page with no
278
+ * Reticle in it — overlay mounted, dock off, nothing on screen. The user cannot tell "I forgot to
279
+ * start the daemon" from "the install did not work", and the commonest cause is the cheapest to
280
+ * say: the port. So the HUD appears, states the URL it tried, and marks itself unreachable so it
281
+ * is never mistaken for a live session.
282
+ *
283
+ * Not an error dialog and not modal: the page is the user's, and a dev overlay that shouts is one
284
+ * they turn off. It is the same capsule they would have had, saying the one thing it knows.
285
+ */
286
+ showUnreachable(url, attempts) {
287
+ if (this.#sessionActive)
288
+ return;
289
+ this.#root?.setAttribute(STATE_ATTR, UNREACHABLE_STATE);
290
+ // Dock and HUD, but NOT the border glow: the glow means the agent is working, and nothing is.
291
+ const dock = this.#root?.querySelector('[data-reticle-dock]');
292
+ dock?.setAttribute(DATA_ON, GLOW_ON);
293
+ this.#hud?.setAttribute(DATA_ON, GLOW_ON);
294
+ this.#lastActionText = unreachableStripText(url, attempts);
295
+ this.#paintActStrip(this.#lastActionText, true);
296
+ // The message IS the reason this state exists, and a collapsed capsule hides it. Same setting
297
+ // a live session honours, so a user who wants the bare toolbar still gets one.
298
+ if (getPresenterSettings().autoOpenChat)
299
+ this.#shell.openChat();
300
+ }
266
301
  /** Turn the base border (session mode) + the HUD/log on - the visible "session is live" state. */
267
302
  #showSession() {
268
303
  const dock = this.#root?.querySelector('[data-reticle-dock]');
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The testids the page is actually showing, read from the live DOM.
3
+ *
4
+ * `registerCapabilities({ testids: [...] })` used to be the only source, and `init` filled it by
5
+ * grepping the source at install time. That list is a snapshot of the codebase on the day `init` ran:
6
+ * it goes stale on the next commit that renames an element, it is empty for every app whose testids
7
+ * arrive with a lazy route, and finishing it was homework handed to whoever ran the install.
8
+ *
9
+ * The attribute in the DOM is the same declaration, read fresh, so nothing has to be copied into a
10
+ * config file to be true. Reticle's own presenter markup is excluded — advertising our own controls
11
+ * as the app's testable surface would be the tool describing itself.
12
+ */
13
+ /**
14
+ * Ceiling on the reported list.
15
+ *
16
+ * A virtualized 500-row grid stamps a testid per cell, and the capabilities payload rides in every
17
+ * announce — an uncapped read turns one long table into a permanently expensive message. The cap is
18
+ * on the REPORT, not on what can be driven: a testid past it is still findable by query.
19
+ */
20
+ export declare const MAX_AUTO_TESTIDS = 200;
21
+ export declare function domTestids(doc: Document): string[];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The testids the page is actually showing, read from the live DOM.
3
+ *
4
+ * `registerCapabilities({ testids: [...] })` used to be the only source, and `init` filled it by
5
+ * grepping the source at install time. That list is a snapshot of the codebase on the day `init` ran:
6
+ * it goes stale on the next commit that renames an element, it is empty for every app whose testids
7
+ * arrive with a lazy route, and finishing it was homework handed to whoever ran the install.
8
+ *
9
+ * The attribute in the DOM is the same declaration, read fresh, so nothing has to be copied into a
10
+ * config file to be true. Reticle's own presenter markup is excluded — advertising our own controls
11
+ * as the app's testable surface would be the tool describing itself.
12
+ */
13
+ const TESTID_ATTR = 'data-testid';
14
+ const TESTID_SELECTOR = `[${TESTID_ATTR}]`;
15
+ /**
16
+ * Ceiling on the reported list.
17
+ *
18
+ * A virtualized 500-row grid stamps a testid per cell, and the capabilities payload rides in every
19
+ * announce — an uncapped read turns one long table into a permanently expensive message. The cap is
20
+ * on the REPORT, not on what can be driven: a testid past it is still findable by query.
21
+ */
22
+ export const MAX_AUTO_TESTIDS = 200;
23
+ /** Reticle's own UI carries testids; they are not the host app's surface. */
24
+ const RETICLE_OWN_PREFIX = 'reticle-';
25
+ export function domTestids(doc) {
26
+ const out = [];
27
+ const seen = new Set();
28
+ let nodes;
29
+ try {
30
+ nodes = doc.querySelectorAll(TESTID_SELECTOR);
31
+ }
32
+ catch {
33
+ return out; // a document that cannot be queried (detached, hostile) simply advertises nothing
34
+ }
35
+ for (let i = 0; i < nodes.length && out.length < MAX_AUTO_TESTIDS; i += 1) {
36
+ const value = nodes[i]?.getAttribute(TESTID_ATTR);
37
+ if (null === value || undefined === value)
38
+ continue;
39
+ const trimmed = value.trim();
40
+ if (0 === trimmed.length || seen.has(trimmed))
41
+ continue;
42
+ if (trimmed.startsWith(RETICLE_OWN_PREFIX))
43
+ continue;
44
+ seen.add(trimmed);
45
+ out.push(trimmed);
46
+ }
47
+ return out;
48
+ }
@@ -8,6 +8,15 @@ export interface Capabilities {
8
8
  signals: string[];
9
9
  stores: string[];
10
10
  flows: CapabilityFlow[];
11
+ /**
12
+ * True when Reticle's OWN presenter is visible to snapshots and queries.
13
+ *
14
+ * Present only when the hatch is open, and reported here rather than left implicit, because a
15
+ * verdict drawn against Reticle's own interface is not an ordinary verdict: an agent that can see
16
+ * the impact panel can also assert against it. Anybody reading a result should be able to tell
17
+ * which kind they are holding without going to look at a build config.
18
+ */
19
+ presenterExposed?: boolean;
11
20
  }
12
21
  /** What the host app passes to reticle.describe; all fields optional. */
13
22
  export interface CapabilitiesInput {
@@ -22,5 +31,23 @@ export declare function setCapabilitiesListener(cb: (() => void) | undefined): v
22
31
  export declare function registerCapabilities(input: CapabilitiesInput): void;
23
32
  /** Snapshot copy of the registered capabilities (defensive — never hand out the live arrays). */
24
33
  export declare function getCapabilities(): Capabilities;
25
- /** Whether the app has advertised any capabilities (used in the HELLO flag). */
34
+ /**
35
+ * Testids the app DECLARED, without the ones merely observed in the DOM.
36
+ *
37
+ * The difference carries meaning the merged list cannot. `knownEmptyState` asks "did the app NAME
38
+ * this element as part of its testable surface?", which is how a zero-match query against
39
+ * `cart-empty-region` is told apart from a broken selector. Every testid on the page is observed, so
40
+ * answering that over the merged list makes it true whenever the page has any testid at all — the
41
+ * same as not asking.
42
+ */
43
+ export declare function declaredTestids(): string[];
44
+ /**
45
+ * Whether the app has a testable surface at all (used in the HELLO flag).
46
+ *
47
+ * Answered over the OBSERVED surface as well as the declared one. It used to be answerable only by
48
+ * declaration, so an app with two hundred testids in its DOM and a live Redux store reported
49
+ * `hasCapabilities: false` until somebody typed those facts into a config file — and the agent
50
+ * reading that flag concluded, correctly by the flag and wrongly in fact, that there was nothing to
51
+ * drive.
52
+ */
26
53
  export declare function hasCapabilities(): boolean;