@khanglvm/relay 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,22 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
92
92
  npm test # zero-dep smoke tests (spawns real servers, fake-submits)
93
93
  ```
94
94
 
95
+ ## Changelog
96
+
97
+ ### 0.6.0 — comment on anything
98
+ - **Comment on any part of a custom-HTML mockup.** Hover any element — a heading,
99
+ a button, a card, the price — and a pin appears to leave an inline note. No
100
+ setup needed; the agent writes zero annotation code. Want to scope it? Mark
101
+ specific elements with `data-relay-annotate="label"`.
102
+ - **Radio questions can carry a note.** Pick an option *and* say why, in one
103
+ optional field — now shown by default (set `"note": false` to hide it).
104
+ - **Edge-to-edge fullscreen** for charts and HTML mockups, with the toolbar
105
+ pinned to the top while you scroll.
106
+ - The board **title and intro are commentable** too.
107
+
108
+ ### 0.5.0
109
+ - Visual answer options, image blocks, viewer redesign, adoption rules.
110
+
95
111
  ## Migration from quest-board
96
112
 
97
113
  relay was formerly `@khanglvm/quest-board` (CLI: `qbd`) — that package is
package/docs/AGENT.md CHANGED
@@ -6,9 +6,10 @@ then **wait for them to click Submit** and read the answers as JSON from stdout.
6
6
  No "type 'done' in the terminal", no hand-rolled HTML+server.
7
7
 
8
8
  **Tell the user** at the start of your intro text that they can hover chart
9
- points, diagram nodes, and table cells to leave comments, and select text in
10
- markdown blocks to annotate — their comments come back in `result.annotations`
11
- alongside their answers. Treat annotations as first-class feedback.
9
+ points, diagram nodes, table cells, and any element of a custom-HTML block to
10
+ leave comments, and select text in markdown blocks to annotate — their comments
11
+ come back in `result.annotations` alongside their answers. Treat annotations as
12
+ first-class feedback.
12
13
 
13
14
  Everything machine-relevant is on **stdout as JSON**; human-facing logs go to
14
15
  stderr. Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
@@ -137,7 +138,9 @@ boolean/bool/yn→yesno, input→text, longtext→textarea, rating/likert→scal
137
138
 
138
139
  Unanswered questions are absent from `answers` and listed in `skipped`.
139
140
  Questions with `"note": true` show a small optional free-text field; non-empty
140
- notes come back in `notes` keyed by question id. On `timeout`/`cancelled`, a
141
+ notes come back in `notes` keyed by question id. **`single` (radio) questions
142
+ show this note by default** so the user can qualify their pick — set
143
+ `"note": false` to hide it. On `timeout`/`cancelled`, a
141
144
  `draft` field carries the autosaved partial answers and any annotations written
142
145
  so far.
143
146
 
@@ -295,23 +298,40 @@ Rules of thumb:
295
298
  background/text match the user's current theme. **Full documents** are served
296
299
  verbatim and receive a `?theme=light|dark` query param on theme toggle.
297
300
 
298
- ### kit.js make iframe elements annotatable
301
+ ### Custom HTML is hover-commentable automatically
299
302
 
300
- Load `/kit.js` inside your custom HTML iframe to let users comment on specific
301
- elements:
303
+ Every custom-HTML block is annotatable out of the box relay injects a tiny
304
+ runtime that lets the user **hover any meaningful element** (headings,
305
+ paragraphs, list items, buttons, images, cards, table cells…) to get a comment
306
+ pin, exactly like the rest of the board. You don't have to do anything. Comments
307
+ come back in `result.annotations` with `target.kind = "html-element"`, a stable
308
+ `target.ref` (the element), and a `target.label` derived from the element.
309
+
310
+ Reach for the controls below only when you want to **scope or label** what's
311
+ annotatable — typically for an interactive prototype where blanket hover targets
312
+ would get in the way:
313
+
314
+ **Declarative signal (preferred)** — mark the elements you want commented. Any
315
+ signal present switches the auto-pick off, so only your marked elements are
316
+ annotatable:
317
+
318
+ ```html
319
+ <button data-relay-annotate="Primary CTA" data-relay-detail="checkout flow">Buy now</button>
320
+ <section data-relay-annotate="Pricing table">…</section>
321
+ ```
322
+
323
+ **Imperative** — same effect from script (needs `/kit.js`, which relay also
324
+ auto-loads):
302
325
 
303
326
  ```html
304
327
  <script src="/kit.js"></script>
305
328
  <script>
306
329
  relayKit.commentable(document.getElementById('chart'), 'Revenue chart', 'Q1 2026');
307
- relayKit.commentable(document.getElementById('hero-cta'), 'CTA button');
308
330
  </script>
309
331
  ```
310
332
 
311
- `relayKit.commentable(el, label, detail?)` outlines `el` on hover; a click
312
- opens the annotation popover in the parent page anchored to the element.
313
- Annotations come back in `result.annotations` with `target.kind = "html-element"`,
314
- `target.label`, and (if provided) `target.detail`.
333
+ **Opt out** of element annotation for a block with `data-relay-annotate="off"`
334
+ on `<html>` or `<body>`.
315
335
 
316
336
  ## Annotations
317
337
 
@@ -349,7 +369,7 @@ intro. Annotations are autosaved with the draft and returned in the final result
349
369
  | `graphviz-node` | `nodeId`, `text` | clicking a Graphviz node or edge |
350
370
  | `table-cell` | `row` (0-based), `col` (column key), `value` | clicking a table cell |
351
371
  | `text` | `quote`, `prefix` (≤30 chars before), `suffix` (≤30 after) | selecting text in a markdown block |
352
- | `html-element` | `label`, `detail?` | clicking a `relayKit.commentable()` element |
372
+ | `html-element` | `ref`, `label`, `detail?` | hovering any element in a custom-HTML block (auto), or a `data-relay-annotate` / `relayKit.commentable()` element |
353
373
  | `image` | `label` | clicking a PlantUML diagram or an image block |
354
374
 
355
375
  Read annotations as first-class feedback — they often carry the sharpest insight
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Browser-based question boards with rich blocks (markdown, charts, mermaid, tables, code, sandboxed HTML) and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, wait for submit, read answers as JSON.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -93,7 +93,8 @@ submit with unanswered questions (returned in `skipped`) unless
93
93
 
94
94
  Set `"note": true` on a question to add a small optional free-text field under
95
95
  it — use when the user may want to qualify their choice. Returned as
96
- `result.notes[questionId]`.
96
+ `result.notes[questionId]`. `single` (radio) questions include this note by
97
+ default (so a pick can carry a comment); set `"note": false` to hide it.
97
98
 
98
99
  Quick one-liners without a spec file:
99
100
 
@@ -153,8 +154,12 @@ pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
153
154
 
154
155
  ## Annotations
155
156
 
156
- Users can hover chart points, diagram nodes (mermaid + graphviz), table cells, or
157
- select text in markdown to leave inline comments. Always mention this in the board intro.
157
+ Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
158
+ any element of a custom-HTML block, or select text in markdown to leave inline
159
+ comments. Custom HTML is hover-commentable automatically — to scope/label what's
160
+ annotatable, mark elements with `data-relay-annotate="Label"` (any signal turns
161
+ the auto-pick off); opt a block out with `data-relay-annotate="off"`. Always
162
+ mention annotation in the board intro.
158
163
 
159
164
  `result.annotations` is an array of:
160
165
 
package/src/server.js CHANGED
@@ -230,16 +230,35 @@ function sendFromDir(res, dir, name, contentType) {
230
230
  return true;
231
231
  }
232
232
 
233
+ // Loaded into every custom-HTML iframe so users can hover any element to leave a
234
+ // comment (relayKit.annotate.auto). Idempotent with an author-added /kit.js, and
235
+ // a no-op when the author opts out via data-relay-annotate="off".
236
+ const ANNOTATE_BOOTSTRAP =
237
+ '<script>(function(){function go(){try{window.relayKit&&window.relayKit.annotate&&window.relayKit.annotate.auto();}catch(e){}}' +
238
+ 'if(window.relayKit&&window.relayKit.annotate)return go();' +
239
+ "var s=document.createElement('script');s.src='/kit.js';s.onload=go;s.onerror=go;" +
240
+ '(document.head||document.documentElement).appendChild(s);})();<\/script>';
241
+
242
+ // Insert a snippet right before </body> (else </html>, else append).
243
+ function injectBeforeBodyEnd(html, snippet) {
244
+ const lower = html.toLowerCase();
245
+ let idx = lower.lastIndexOf('</body>');
246
+ if (idx === -1) idx = lower.lastIndexOf('</html>');
247
+ if (idx === -1) return html + snippet;
248
+ return html.slice(0, idx) + snippet + html.slice(idx);
249
+ }
250
+
233
251
  // Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
234
252
  // matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
235
253
  // block in dark mode. Full documents are served verbatim — their authors can
236
- // read the ?theme=light|dark query param themselves.
254
+ // read the ?theme=light|dark query param themselves. Either way the annotate
255
+ // bootstrap is injected so every element is hover-commentable.
237
256
  function wrapFragment(content, theme) {
238
- if (/<html[\s>]/i.test(content)) return content;
257
+ if (/<html[\s>]/i.test(content)) return injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP);
239
258
  const dark = theme === 'dark';
240
259
  const bg = dark ? '#282624' : '#ffffff';
241
260
  const fg = dark ? '#edeae4' : '#1c1b19';
242
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}</body></html>`;
261
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${ANNOTATE_BOOTSTRAP}</body></html>`;
243
262
  }
244
263
 
245
264
  function readBody(req, limit = 5 * 1024 * 1024) {
package/src/spec.js CHANGED
@@ -332,7 +332,10 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
332
332
  label,
333
333
  description: asStr(rq.description),
334
334
  required: rq.required === true,
335
- note: rq.note === true,
335
+ // Radio (single) questions show the optional per-answer note by default so
336
+ // the user can qualify their pick; other types stay opt-in. An explicit
337
+ // note:false turns it off for a single question.
338
+ note: rq.note === undefined ? type === 'single' : rq.note === true,
336
339
  blocks: buildBlocks(rq, cwd, where, `${id}-`),
337
340
  placeholder: asStr(rq.placeholder),
338
341
  };
@@ -486,7 +489,7 @@ export const SPEC_SCHEMA = {
486
489
  },
487
490
  },
488
491
  other: { type: 'boolean', default: false, description: 'single/multi: add a free-text "Other" option. Its text is returned verbatim as the value.' },
489
- note: { type: 'boolean', default: false, description: 'Add a small optional free-text field under the question (e.g. to qualify a choice). Returned separately as result.notes[questionId].' },
492
+ note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
490
493
  placeholder: { type: 'string', description: 'For text/textarea.' },
491
494
  default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
492
495
  min: { type: 'integer', default: 1, description: 'scale only' },
@@ -504,7 +507,7 @@ export const SPEC_SCHEMA = {
504
507
  type: 'array',
505
508
  readOnly: true,
506
509
  description:
507
- 'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"table-cell"|"text"|"html-element", …}, text, createdAt}.',
510
+ 'Returned in the result (not part of the input spec). Element-level comments the user attached to blocks. Each: {id, questionId|null, blockId|null, target:{kind:"chart-element"|"mermaid-node"|"graphviz-node"|"table-cell"|"text"|"html-element"|"image", …}, text, createdAt}. For html-element, target carries a stable ref + label; users can hover any element of a custom-HTML block (automatic) or ones the author marked with data-relay-annotate.',
508
511
  },
509
512
  blockEdits: {
510
513
  type: 'object',
package/src/ui/app.js CHANGED
@@ -232,6 +232,9 @@
232
232
  onChange: (list) => {
233
233
  state.annotations = list;
234
234
  scheduleSave();
235
+ // Refresh per-element comment badges inside custom-HTML iframes (bridge
236
+ // sets this once the iframe annotate plumbing is wired below).
237
+ if (window.__relayBroadcastCounts) window.__relayBroadcastCounts();
235
238
  },
236
239
  });
237
240
  }
@@ -516,7 +519,13 @@
516
519
  // sessionStorage may be unavailable (privacy mode) — non-fatal
517
520
  }
518
521
 
519
- app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
522
+ const titleEl = el('h1', {}, spec.title);
523
+ app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
524
+ // The board title is commentable too: hovering it shows the pin, like the
525
+ // intro and every other content element. (themeBtn stays out of it.)
526
+ if (spec.title) {
527
+ Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
528
+ }
520
529
  if (spec.intro) {
521
530
  const intro = el('p', { class: 'intro' }, spec.intro);
522
531
  app.append(intro);
@@ -581,7 +590,7 @@
581
590
  const hint = el('span', { class: 'hint' },
582
591
  QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
583
592
  app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
584
- app.append(el('footer', { class: 'qb-footer' }, `quest-board · ${boot.boardId}`));
593
+ app.append(el('footer', { class: 'qb-footer' }, `relay · ${boot.boardId}`));
585
594
 
586
595
  // ---------- validation & submit ----------
587
596
  function validate() {
@@ -664,33 +673,66 @@
664
673
  }
665
674
 
666
675
  // ---------- iframe annotate bridge ----------
667
- // Custom-HTML iframes (via /kit.js relayKit.commentable) post
668
- // {relay:'annotate-request', label, detail?}. Match the source to the
669
- // iframe, read its block/question ids, and open the annotate popover anchored
670
- // to that iframe.
676
+ // Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
677
+ // server) talk to the parent over postMessage:
678
+ // iframe {relay:'annotate-ready'} we send it counts
679
+ // iframe {relay:'annotate-request', ref, label, detail?, rect}
680
+ // parent → {relay:'annotate-counts', counts:{ref:n}} it draws badges
681
+ // We own the annotation state, popover, and the submitted result; the iframe
682
+ // owns hover/pin/badges over its own (cross-origin) DOM.
671
683
  if (Annotate) {
672
- window.addEventListener('message', (e) => {
673
- const msg = e.data;
674
- if (!msg || typeof msg !== 'object' || msg.relay !== 'annotate-request') return;
675
- let frame = null;
684
+ const frameOf = (source) => {
676
685
  for (const f of document.querySelectorAll('iframe.viz')) {
677
- if (f.contentWindow === e.source) { frame = f; break; }
686
+ if (f.contentWindow === source) return f;
678
687
  }
688
+ return null;
689
+ };
690
+ // Per-element comment counts for one iframe, keyed by target.ref.
691
+ const postCountsTo = (frame) => {
692
+ if (!frame || !frame.contentWindow) return;
693
+ const blockId = frame.getAttribute('data-block-id') || null;
694
+ const counts = {};
695
+ for (const a of Annotate.list()) {
696
+ if ((a.blockId ?? null) !== (blockId ?? null)) continue;
697
+ const t = a.target || {};
698
+ if (t.kind !== 'html-element' || !t.ref) continue;
699
+ counts[t.ref] = (counts[t.ref] || 0) + 1;
700
+ }
701
+ try { frame.contentWindow.postMessage({ relay: 'annotate-counts', counts }, '*'); } catch {}
702
+ };
703
+ // Refresh badges in every html iframe (called whenever annotations change).
704
+ window.__relayBroadcastCounts = () => {
705
+ for (const f of document.querySelectorAll('iframe.viz')) postCountsTo(f);
706
+ };
707
+
708
+ window.addEventListener('message', (e) => {
709
+ const msg = e.data;
710
+ if (!msg || typeof msg !== 'object' || typeof msg.relay !== 'string') return;
711
+ const frame = frameOf(e.source);
679
712
  if (!frame) return;
713
+ if (msg.relay === 'annotate-ready') { postCountsTo(frame); return; }
714
+ if (msg.relay !== 'annotate-request') return;
715
+
680
716
  const blockId = frame.getAttribute('data-block-id') || null;
681
717
  const questionId = frame.getAttribute('data-question-id') || null;
682
- Annotate.openExternal(
683
- {
684
- blockId,
685
- questionId: questionId || null,
686
- target: {
687
- kind: 'html-element',
688
- label: typeof msg.label === 'string' ? msg.label : 'Element',
689
- detail: typeof msg.detail === 'string' ? msg.detail : undefined,
690
- },
691
- },
692
- frame
693
- );
718
+ const target = { kind: 'html-element', label: typeof msg.label === 'string' ? msg.label : 'Element' };
719
+ if (typeof msg.ref === 'string') target.ref = msg.ref;
720
+ if (typeof msg.detail === 'string') target.detail = msg.detail;
721
+
722
+ // Anchor the popover to the actual element: translate the iframe-local
723
+ // viewport rect the iframe sent into page coords. Fall back to the iframe.
724
+ let anchor = frame;
725
+ if (msg.rect && typeof msg.rect === 'object') {
726
+ const fr = frame.getBoundingClientRect();
727
+ const r = msg.rect;
728
+ const left = fr.left + (r.left || 0);
729
+ const top = fr.top + (r.top || 0);
730
+ const width = r.width || 0;
731
+ const height = r.height || 0;
732
+ const pageRect = { left, top, width, height, right: left + width, bottom: top + height };
733
+ anchor = { getBoundingClientRect: () => pageRect };
734
+ }
735
+ Annotate.openExternal({ blockId, questionId: questionId || null, target }, anchor);
694
736
  });
695
737
  }
696
738
 
package/src/ui/blocks.css CHANGED
@@ -307,14 +307,28 @@
307
307
  .blk-tools .tool-pct { color: var(--muted); font-size: 0.72rem; min-width: 30px; text-align: center; }
308
308
 
309
309
  /* full-screen = fixed overlay (NOT native fullscreen: annotation pins/badges/
310
- popover live on <body> and must stay visible above the expanded block) */
310
+ popover live on <body> and must stay visible above the expanded block).
311
+ Edge-to-edge (no modal gap); the toolbar pins to the top as a fixed bar so
312
+ the close button stays on screen while the content scrolls. */
311
313
  .blk-full {
312
- position: fixed !important; inset: 12px; z-index: 49;
313
- background: var(--card); border: 1px solid var(--border);
314
- border-radius: 12px; box-shadow: var(--shadow-lift);
314
+ position: fixed !important; inset: 0; z-index: 49;
315
+ background: var(--card); border: 0; border-radius: 0; box-shadow: none;
315
316
  overflow: auto; max-height: none !important; margin: 0 !important;
316
- padding: 48px 16px 16px;
317
+ padding: 44px 0 0;
317
318
  }
318
319
  body.blk-full-open { overflow: hidden; }
319
- .blk-chart.blk-full { height: calc(100vh - 24px) !important; width: auto !important; }
320
- .blk-htmlwrap.blk-full iframe.viz { height: calc(100vh - 90px) !important; width: 100%; }
320
+ /* detach the toolbar from the scrolling content and fix it to the viewport top */
321
+ .blk-full > .blk-tools {
322
+ position: fixed; top: 0; left: 0; right: 0; height: 44px;
323
+ margin: 0; padding: 0 12px;
324
+ background: var(--card); border-bottom: 1px solid var(--border);
325
+ z-index: 51;
326
+ }
327
+ .blk-chart.blk-full {
328
+ height: 100vh !important; width: auto !important;
329
+ padding: 44px 16px 16px;
330
+ }
331
+ .blk-htmlwrap.blk-full iframe.viz {
332
+ height: calc(100vh - 44px) !important; width: 100%;
333
+ margin: 0; border: 0; border-radius: 0;
334
+ }
package/src/ui/kit.js CHANGED
@@ -32,13 +32,26 @@
32
32
  * sortable: click column header to sort ascending/descending.
33
33
  *
34
34
  * relayKit.commentable(el, label, detail?)
35
- * Hover: 1px dashed accent outline + cursor pointer + title "add a comment".
36
- * Click: parent.postMessage({relay:'annotate-request', label, detail}, '*').
35
+ * Make one element commentable: hover shows a comment pin, a per-element
36
+ * badge counts comments, click opens the board's annotation popover anchored
37
+ * to the element. Thin shim over relayKit.annotate.register().
38
+ *
39
+ * relayKit.annotate.auto()
40
+ * Injected automatically by the server into every custom-HTML iframe. With no
41
+ * explicit signals it makes a sensible set of content/interactive elements
42
+ * hover-commentable so the user can annotate ANY meaningful part of the HTML.
43
+ * If the author marks elements with data-relay-annotate[="Label"] (+ optional
44
+ * data-relay-detail) or calls commentable(), auto-mode backs off to just those.
45
+ * Opt out with data-relay-annotate="off" on <html> or <body>.
37
46
  */
38
47
 
39
48
  (function () {
40
49
  'use strict';
41
50
 
51
+ // Idempotent: the server may inject a /kit.js load on top of one the author
52
+ // already added. First load wins; a second execution no-ops.
53
+ if (window.relayKit) return;
54
+
42
55
  // ---------------------------------------------------------------------------
43
56
  // Theme
44
57
  // ---------------------------------------------------------------------------
@@ -357,39 +370,333 @@
357
370
  }
358
371
 
359
372
  // ---------------------------------------------------------------------------
360
- // commentable()
373
+ // annotate — element-level commenting inside the sandboxed iframe
361
374
  // ---------------------------------------------------------------------------
375
+ // The board's annotation engine lives in the PARENT page and can't reach into
376
+ // this cross-origin iframe (sandbox without allow-same-origin), so everything
377
+ // here runs iframe-side and talks to the parent over postMessage:
378
+ // iframe → parent {relay:'annotate-ready'} request counts
379
+ // iframe → parent {relay:'annotate-request', ref, label, detail?, rect}
380
+ // parent → iframe {relay:'annotate-counts', counts:{ref:n}} draw badges
381
+ //
382
+ // `ref` is a compact, reload-stable CSS path used as the element's identity so
383
+ // comments re-bind and badges count per element.
384
+
385
+ const annotate = (() => {
386
+ // Auto-mode picks anything the user would plausibly point at: every element
387
+ // with its OWN direct text (so div/span-based mockups work, not just
388
+ // semantic tags), plus meaningful leaves (icons, buttons, media) and a few
389
+ // semantic containers worth commenting whole. closest() resolves overlaps to
390
+ // the innermost target on hover.
391
+ const LEAF_TAGS = /^(IMG|BUTTON|A|INPUT|SELECT|TEXTAREA|svg|VIDEO|CANVAS|SUMMARY)$/;
392
+ const SKIP_TAGS = /^(SCRIPT|STYLE|HEAD|META|LINK|TITLE|BASE|NOSCRIPT|TEMPLATE)$/;
393
+ const CONTAINER_SELECTOR =
394
+ 'li,td,th,figure,blockquote,article,.card,[role="button"],[role="listitem"],[role="option"]';
395
+ const MAX_AUTO = 400;
396
+ // Replaced elements that can't host a badge child — overlay the badge instead.
397
+ const NO_CHILD = /^(IMG|INPUT|HR|BR|EMBED|CANVAS|VIDEO|svg)$/;
398
+
399
+ const byRef = new Map(); // ref -> element
400
+ let mode = null; // 'auto' | 'explicit' (decided on first scan)
401
+ let started = false;
402
+ let hot = null; // currently outlined element
403
+ let pin = null; // floating pin button
404
+ let hideTimer = 0;
405
+ let lastCounts = {};
406
+ let overlayBadges = []; // [{el, badge}] for replaced elements
407
+ let repoTimer = 0;
408
+
409
+ const accent = () => theme.colors.accent;
410
+
411
+ // Compact, reload-stable CSS path — the element's identity.
412
+ function refOf(el) {
413
+ if (el.__relayRef) return el.__relayRef;
414
+ const parts = [];
415
+ let n = el;
416
+ while (n && n.nodeType === 1 && n !== document.body && parts.length < 10) {
417
+ let seg = n.tagName.toLowerCase();
418
+ const p = n.parentNode;
419
+ if (p && p.children) {
420
+ const sibs = Array.prototype.filter.call(p.children, (c) => c.tagName === n.tagName);
421
+ if (sibs.length > 1) seg += ':nth-of-type(' + (sibs.indexOf(n) + 1) + ')';
422
+ }
423
+ parts.unshift(seg);
424
+ n = p;
425
+ }
426
+ const ref = parts.join('>') || el.tagName.toLowerCase();
427
+ el.__relayRef = ref;
428
+ return ref;
429
+ }
362
430
 
363
- function commentable(el, label, detail) {
364
- try {
365
- const lStr = String(label || '').slice(0, 200);
366
- const dStr = detail != null ? String(detail).slice(0, 500) : undefined;
367
- const accent = theme.colors.accent;
431
+ // Human label when the author didn't supply one.
432
+ function labelOf(el) {
433
+ const aria = el.getAttribute && el.getAttribute('aria-label');
434
+ if (aria) return aria.trim().slice(0, 80) || el.tagName.toLowerCase();
435
+ if (el.tagName === 'IMG') return (el.getAttribute('alt') || 'Image').trim().slice(0, 80) || 'Image';
436
+ const txt = (el.textContent || '').replace(/\s+/g, ' ').trim();
437
+ return txt ? txt.slice(0, 80) : el.tagName.toLowerCase();
438
+ }
368
439
 
369
- el.style.cursor = 'pointer';
370
- el.title = 'add a comment';
440
+ function injectStyle() {
441
+ if (document.getElementById('relay-ann-style')) return;
442
+ const s = document.createElement('style');
443
+ s.id = 'relay-ann-style';
444
+ s.textContent = [
445
+ '.relay-ann-hot{outline:2px solid ' + accent() + ' !important;outline-offset:1px !important;}',
446
+ '.relay-ann-badge{position:absolute;top:-7px;right:-7px;min-width:16px;height:16px;padding:0 4px;' +
447
+ 'box-sizing:border-box;border-radius:9px;background:' + accent() + ';color:#fff;font:600 10px/16px ' + SANS + ';' +
448
+ 'text-align:center;z-index:2147483646;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.25);}',
449
+ ].join('\n');
450
+ (document.head || document.documentElement).appendChild(s);
451
+ }
371
452
 
372
- el.addEventListener('mouseenter', () => {
373
- el.style.outline = `1px dashed ${accent}`;
374
- el.style.outlineOffset = '2px';
375
- });
376
- el.addEventListener('mouseleave', () => {
377
- el.style.outline = '';
378
- el.style.outlineOffset = '';
379
- });
380
- el.addEventListener('click', (e) => {
453
+ function ensurePin() {
454
+ if (pin || !document.body) return pin;
455
+ pin = document.createElement('button');
456
+ pin.type = 'button';
457
+ pin.setAttribute('aria-label', 'Add a comment');
458
+ pin.title = 'Add a comment';
459
+ pin.style.cssText = [
460
+ 'position:fixed', 'z-index:2147483647', 'display:none', 'width:22px', 'height:22px',
461
+ 'padding:0', 'border:none', 'border-radius:50%', 'cursor:pointer', 'background:' + accent(),
462
+ 'color:#fff', 'align-items:center', 'justify-content:center', 'line-height:0',
463
+ 'box-shadow:0 1px 4px rgba(0,0,0,.3)',
464
+ ].join(';');
465
+ pin.innerHTML =
466
+ '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
467
+ '<path d="M3 2.5h10A1.5 1.5 0 0 1 14.5 4v5a1.5 1.5 0 0 1-1.5 1.5H8.4L5 13.4v-2.9H3A1.5 1.5 0 0 1 1.5 9V4A1.5 1.5 0 0 1 3 2.5Z" fill="currentColor"/></svg>';
468
+ pin.addEventListener('mouseenter', () => clearTimeout(hideTimer));
469
+ pin.addEventListener('mouseleave', scheduleHide);
470
+ pin.addEventListener('click', (e) => {
471
+ e.preventDefault();
381
472
  e.stopPropagation();
382
- const msg = { relay: 'annotate-request', label: lStr };
383
- if (dStr !== undefined) msg.detail = dStr;
384
- try {
385
- parent.postMessage(msg, '*');
386
- } catch (postErr) {
387
- console.warn('[relayKit] commentable postMessage error:', postErr);
473
+ if (hot) request(hot);
474
+ hidePin();
475
+ });
476
+ document.body.appendChild(pin);
477
+ return pin;
478
+ }
479
+
480
+ function showPinFor(el) {
481
+ if (!ensurePin()) return;
482
+ clearTimeout(hideTimer);
483
+ if (hot && hot !== el) hot.classList.remove('relay-ann-hot');
484
+ hot = el;
485
+ el.classList.add('relay-ann-hot');
486
+ const r = el.getBoundingClientRect();
487
+ const w = 22;
488
+ pin.style.display = 'flex';
489
+ pin.style.left = Math.max(2, Math.min(r.right - w / 2, window.innerWidth - w - 2)) + 'px';
490
+ pin.style.top = Math.max(2, Math.min(r.top - w / 2, window.innerHeight - w - 2)) + 'px';
491
+ }
492
+
493
+ function scheduleHide() {
494
+ clearTimeout(hideTimer);
495
+ hideTimer = setTimeout(hidePin, 220); // grace so the pin itself stays clickable
496
+ }
497
+
498
+ function hidePin() {
499
+ clearTimeout(hideTimer);
500
+ if (hot) hot.classList.remove('relay-ann-hot');
501
+ hot = null;
502
+ if (pin) pin.style.display = 'none';
503
+ }
504
+
505
+ // Ask the parent to open its annotation popover for this element.
506
+ function request(el) {
507
+ const r = el.getBoundingClientRect();
508
+ const msg = {
509
+ relay: 'annotate-request',
510
+ ref: refOf(el),
511
+ label: String(el.__relayLabel || labelOf(el)).slice(0, 200),
512
+ rect: { left: r.left, top: r.top, width: r.width, height: r.height },
513
+ };
514
+ if (el.__relayDetail != null) msg.detail = String(el.__relayDetail).slice(0, 500);
515
+ try { parent.postMessage(msg, '*'); } catch (_) {}
516
+ }
517
+
518
+ function positionOverlay(el, badge) {
519
+ const r = el.getBoundingClientRect();
520
+ badge.style.left = (r.right + window.scrollX - 9) + 'px';
521
+ badge.style.top = (r.top + window.scrollY - 7) + 'px';
522
+ badge.style.right = 'auto';
523
+ }
524
+
525
+ function repositionOverlays() {
526
+ for (const o of overlayBadges) if (o.el.isConnected) positionOverlay(o.el, o.badge);
527
+ }
528
+
529
+ function renderBadges(counts) {
530
+ lastCounts = counts || {};
531
+ for (const b of document.querySelectorAll('.relay-ann-badge')) b.remove();
532
+ overlayBadges = [];
533
+ for (const ref of Object.keys(lastCounts)) {
534
+ const n = lastCounts[ref];
535
+ if (!n) continue;
536
+ const el = byRef.get(ref);
537
+ if (!el || !el.isConnected) continue;
538
+ const badge = document.createElement('span');
539
+ badge.className = 'relay-ann-badge';
540
+ badge.textContent = String(n);
541
+ badge.title = n + (n === 1 ? ' comment' : ' comments');
542
+ badge.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); request(el); });
543
+ if (NO_CHILD.test(el.tagName)) {
544
+ badge.style.position = 'absolute';
545
+ document.body.appendChild(badge);
546
+ overlayBadges.push({ el, badge });
547
+ positionOverlay(el, badge);
548
+ } else {
549
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
550
+ el.appendChild(badge);
388
551
  }
552
+ }
553
+ }
554
+
555
+ function add(el, label, detail) {
556
+ if (!el || el.nodeType !== 1) return;
557
+ byRef.set(refOf(el), el);
558
+ el.classList.add('relay-annotatable');
559
+ if (label != null && label !== '') el.__relayLabel = String(label);
560
+ if (detail != null) el.__relayDetail = detail;
561
+ }
562
+
563
+ // True when the element holds non-whitespace text of its OWN (a direct text
564
+ // node), not just text inherited from descendants.
565
+ function hasDirectText(el) {
566
+ for (const n of el.childNodes) {
567
+ if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim()) return true;
568
+ }
569
+ return false;
570
+ }
571
+
572
+ function autoPick() {
573
+ let all;
574
+ try { all = document.body.querySelectorAll('*'); } catch (_) { return []; }
575
+ let containers;
576
+ try { containers = new Set(document.querySelectorAll(CONTAINER_SELECTOR)); } catch (_) { containers = new Set(); }
577
+ const out = [];
578
+ for (const el of all) {
579
+ if (out.length >= MAX_AUTO) break;
580
+ if (SKIP_TAGS.test(el.tagName)) continue;
581
+ if (pin && pin.contains(el)) continue; // our own pin
582
+ if (el.classList && el.classList.contains('relay-ann-badge')) continue;
583
+ if (LEAF_TAGS.test(el.tagName) || containers.has(el) || hasDirectText(el)) out.push(el);
584
+ }
585
+ return out;
586
+ }
587
+
588
+ function scan() {
589
+ const signalled = Array.prototype.filter.call(
590
+ document.querySelectorAll('[data-relay-annotate]'),
591
+ (el) => el.getAttribute('data-relay-annotate') !== 'off'
592
+ );
593
+ // ANY explicit signal (attribute or a prior commentable() call) → the
594
+ // author scopes what's annotatable; auto-mode stays off.
595
+ if (mode === null) mode = signalled.length > 0 || byRef.size > 0 ? 'explicit' : 'auto';
596
+ if (mode === 'explicit') {
597
+ for (const el of signalled) add(el, el.getAttribute('data-relay-annotate') || null, el.getAttribute('data-relay-detail'));
598
+ return;
599
+ }
600
+ for (const el of autoPick()) add(el, null, undefined);
601
+ }
602
+
603
+ function optedOut() {
604
+ const v = (document.body && document.body.getAttribute('data-relay-annotate')) ||
605
+ document.documentElement.getAttribute('data-relay-annotate');
606
+ return v === 'off';
607
+ }
608
+
609
+ function announce() {
610
+ try { parent.postMessage({ relay: 'annotate-ready' }, '*'); } catch (_) {}
611
+ }
612
+
613
+ function start() {
614
+ if (started) return;
615
+ if (!document.body) { document.addEventListener('DOMContentLoaded', start, { once: true }); return; }
616
+ started = true;
617
+ injectStyle();
618
+ ensurePin();
619
+ // Delegated hover — closest() resolves nested targets to the innermost.
620
+ document.addEventListener('mouseover', (e) => {
621
+ const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
622
+ if (t) showPinFor(t);
623
+ }, true);
624
+ document.addEventListener('mouseout', (e) => {
625
+ const t = e.target && e.target.closest && e.target.closest('.relay-annotatable');
626
+ if (t) scheduleHide();
627
+ }, true);
628
+ window.addEventListener('scroll', () => {
629
+ hidePin();
630
+ if (overlayBadges.length) { cancelAnimationFrame(repoTimer); repoTimer = requestAnimationFrame(repositionOverlays); }
631
+ }, true);
632
+ window.addEventListener('resize', () => { hidePin(); repositionOverlays(); });
633
+ window.addEventListener('message', (e) => {
634
+ const m = e.data;
635
+ if (m && typeof m === 'object' && m.relay === 'annotate-counts') renderBadges(m.counts || {});
389
636
  });
390
- } catch (err) {
391
- console.warn('[relayKit] commentable error:', err);
637
+ // Re-pick up DOM that author JS builds after load. We observe childList
638
+ // only (not attributes, so our own hover-class toggles don't fire it) and
639
+ // ignore mutations that only touch our own pin/badge nodes — otherwise
640
+ // rendering a badge would re-trigger the observer in a tight loop.
641
+ try {
642
+ const ours = (n) =>
643
+ n.nodeType === 1 &&
644
+ (n === pin || (pin && pin.contains(n)) || n.id === 'relay-ann-style' ||
645
+ (n.classList && n.classList.contains('relay-ann-badge')));
646
+ const mo = new MutationObserver((muts) => {
647
+ let relevant = false;
648
+ for (const m of muts) {
649
+ for (const n of m.addedNodes) if (!ours(n)) { relevant = true; break; }
650
+ if (relevant) break;
651
+ for (const n of m.removedNodes) if (!ours(n)) { relevant = true; break; }
652
+ if (relevant) break;
653
+ }
654
+ if (!relevant) return;
655
+ clearTimeout(mo._t);
656
+ mo._t = setTimeout(() => { scan(); renderBadges(lastCounts); announce(); }, 250);
657
+ });
658
+ mo.observe(document.body, { childList: true, subtree: true });
659
+ } catch (_) {}
660
+ announce();
661
+ }
662
+
663
+ // Server-injected entrypoint. Idempotent.
664
+ function auto() {
665
+ try {
666
+ if (optedOut()) return;
667
+ if (!document.body) { document.addEventListener('DOMContentLoaded', auto, { once: true }); return; }
668
+ scan();
669
+ start();
670
+ } catch (err) {
671
+ console.warn('[relayKit] annotate.auto error:', err);
672
+ }
673
+ }
674
+
675
+ // Explicit per-element registration (also used by commentable()).
676
+ function register(el, label, detail) {
677
+ try {
678
+ add(el, label, detail);
679
+ if (started) renderBadges(lastCounts);
680
+ else start();
681
+ } catch (err) {
682
+ console.warn('[relayKit] annotate.register error:', err);
683
+ }
392
684
  }
685
+
686
+ return { auto, register };
687
+ })();
688
+
689
+ // ---------------------------------------------------------------------------
690
+ // commentable() — back-compat shim over annotate.register()
691
+ // ---------------------------------------------------------------------------
692
+
693
+ function commentable(el, label, detail) {
694
+ if (!el) return;
695
+ annotate.register(
696
+ el,
697
+ label != null ? String(label).slice(0, 200) : null,
698
+ detail != null ? String(detail).slice(0, 500) : undefined
699
+ );
393
700
  }
394
701
 
395
702
  // ---------------------------------------------------------------------------
@@ -403,6 +710,7 @@
403
710
  mermaid,
404
711
  table,
405
712
  commentable,
713
+ annotate,
406
714
  };
407
715
 
408
716
  window.relayKit = relayKit;