@plannotator/ui 0.50.0 → 0.51.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 (53) hide show
  1. package/HANDOFF.md +144 -11
  2. package/components/AISettingsTab.tsx +32 -0
  3. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  4. package/components/AgentsTab.tsx +9 -9
  5. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  6. package/components/CommentPopover.tsx +32 -18
  7. package/components/DecisionControl.tsx +2 -7
  8. package/components/DiagramBlock.tsx +46 -4
  9. package/components/DocBadges.tsx +12 -8
  10. package/components/PlanHeaderMenu.tsx +1 -1
  11. package/components/Settings.tsx +73 -1
  12. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  13. package/components/Viewer.tsx +4 -0
  14. package/components/ai/AIProviderBar.tsx +18 -1
  15. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  16. package/components/ai/SessionAskNotice.tsx +33 -21
  17. package/components/blocks/QuestionBlock.tsx +24 -19
  18. package/components/blocks/QuestionContext.tsx +81 -0
  19. package/components/diagram/DiagramComposer.tsx +54 -3
  20. package/components/diagram/DiagramViewer.tsx +31 -2
  21. package/components/diagram/index.ts +1 -1
  22. package/components/html-viewer/HtmlViewer.tsx +13 -3
  23. package/components/html-viewer/bridge-script.asset.js +124 -8
  24. package/components/html-viewer/bridge-script.ts +124 -8
  25. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  26. package/hooks/useAIChat.ts +26 -5
  27. package/hooks/useAIProviderActivation.ts +13 -0
  28. package/hooks/useAIProviderConfig.ts +3 -3
  29. package/hooks/useActiveSection.ts +3 -2
  30. package/hooks/useAgentToolSetting.ts +33 -0
  31. package/hooks/useAnnotationDraft.ts +53 -7
  32. package/hooks/useAnnotationHighlighter.ts +101 -5
  33. package/hooks/useExternalAnnotations.ts +21 -1
  34. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  35. package/hooks/useLatchedTrue.ts +15 -0
  36. package/hooks/useLinkedDoc.ts +64 -4
  37. package/hooks/useModelCatalogs.ts +9 -2
  38. package/package.json +2 -2
  39. package/styles.css +1 -1
  40. package/themes/gruvbox.css +6 -0
  41. package/types.ts +13 -0
  42. package/utils/agentClosed.ts +33 -0
  43. package/utils/agentToolAnnouncement.ts +109 -0
  44. package/utils/agentToolSetting.ts +108 -0
  45. package/utils/aiProvider.ts +13 -40
  46. package/utils/annotationOwnsHighlight.ts +27 -0
  47. package/utils/annotationScope.ts +15 -0
  48. package/utils/askSessionAnnouncement.ts +107 -0
  49. package/utils/blockTargeting.ts +61 -2
  50. package/utils/diagramAskAI.ts +161 -0
  51. package/utils/parser.ts +236 -110
  52. package/utils/questionAnswers.ts +3 -1
  53. package/utils/quickLabels.ts +2 -2
@@ -1,5 +1,5 @@
1
1
  import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
- import type { DiagramKind } from '@plannotator/core/diagram-anchor';
2
+ import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor';
3
3
  import { cn } from '../../lib/utils';
4
4
  import { diagramFamilyOf } from '../../utils/diagram-anchor';
5
5
  import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
@@ -33,6 +33,16 @@ const DiagramSourcePane = lazy(async () => ({ default: (await import('./DiagramS
33
33
  * Never `null` here — that would collapse the row back onto the canvas. */
34
34
  const PANE_CLASS = 'order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0';
35
35
 
36
+ /** A question typed in the composer, asked about the part instead of saved
37
+ * as a comment. The anchor is the one a comment there would carry (its
38
+ * `sourceLine` already in document lines). Resolve false (or throw) to keep
39
+ * the draft open; anything else closes it. */
40
+ export type DiagramAskAI = (
41
+ question: string,
42
+ anchor: DiagramAnchor,
43
+ additionalTargets: readonly DiagramTarget[],
44
+ ) => boolean | void | Promise<boolean | void>;
45
+
36
46
  export interface DiagramViewerProps {
37
47
  readonly kind: DiagramKind;
38
48
  /** The diagram text. With `onSave` this is the saved baseline the pane's
@@ -42,6 +52,8 @@ export interface DiagramViewerProps {
42
52
  readonly comments: readonly DiagramComment[];
43
53
  /** A comment composed on a part. Absent: clicks open nothing. */
44
54
  readonly onCreateComment?: DiagramCreateComment;
55
+ /** Ask AI from the composer. Absent: the composer offers no Ask AI. */
56
+ readonly onAskAI?: DiagramAskAI;
45
57
  /** Save the pane's text. Absent: there is no Source pane. */
46
58
  readonly onSave?: (source: string) => Promise<SaveResult>;
47
59
  /** With `onSave`: show the pane read-only (no edit access). */
@@ -92,6 +104,7 @@ export function DiagramViewer({
92
104
  theme,
93
105
  comments,
94
106
  onCreateComment,
107
+ onAskAI,
95
108
  onSave,
96
109
  readOnlySource = false,
97
110
  sourceOpen = false,
@@ -177,6 +190,21 @@ export function DiagramViewer({
177
190
  if (!sourceOpen) cancelComposer();
178
191
  }, [cancelComposer, sourceOpen]);
179
192
 
193
+ // Ask AI asks about the part the draft is on; the host's handler decides
194
+ // whether the question was taken, and a taken question closes the draft
195
+ // exactly as the markdown composer closes.
196
+ const composerDraft = commentsState.composer;
197
+ const askFromComposer = useMemo(() => {
198
+ if (onAskAI === undefined || composerDraft === null || onCreateComment === undefined) return undefined;
199
+ return async (question: string): Promise<boolean> => {
200
+ const anchor = buildDiagramAnchorValue(composerDraft.primary.target, composerDraft.sourceLine);
201
+ const accepted = await onAskAI(question, anchor, composerDraft.additional.map((extra) => extra.target));
202
+ if (accepted === false) return false;
203
+ cancelComposer();
204
+ return true;
205
+ };
206
+ }, [cancelComposer, composerDraft, onAskAI, onCreateComment]);
207
+
180
208
  const overlay = useCallback(
181
209
  (handle: DiagramCanvasHandle) => (
182
210
  <DiagramOverlay
@@ -199,12 +227,13 @@ export function DiagramViewer({
199
227
  disabledReason={commentingDisabledReason}
200
228
  onSubmit={(text) => void commentsState.submit(text)}
201
229
  onCancel={commentsState.cancel}
230
+ onAskAI={askFromComposer}
202
231
  />
203
232
  )
204
233
  }
205
234
  />
206
235
  ),
207
- [commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
236
+ [askFromComposer, commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
208
237
  );
209
238
 
210
239
  const showFallback = render.svgNode === null;
@@ -6,7 +6,7 @@
6
6
  * `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
7
7
  * `diagram-projection`).
8
8
  */
9
- export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
9
+ export { DiagramViewer, type DiagramAskAI, type DiagramViewerProps } from './DiagramViewer';
10
10
  export { DiagramPopout } from './DiagramPopout';
11
11
  export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
12
12
  export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';
@@ -17,6 +17,7 @@ import {
17
17
  import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
18
18
  import { AnnotationType } from "../../types";
19
19
  import { copyTextPreservingFocus } from "../../utils/clipboard";
20
+ import { elementIdentityForAskAI } from "../../utils/parser";
20
21
  import { getIdentity } from "../../utils/identity";
21
22
  import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
22
23
  import {
@@ -674,6 +675,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
674
675
  }));
675
676
  }, [hook.draftTargets]);
676
677
 
678
+ // Ask AI from a pinpoint draft: which element(s) the question is about,
679
+ // in the identity lines the feedback export prints (no outline).
680
+ const askAIElementDetail = useMemo(
681
+ () => elementIdentityForAskAI(hook.draftTargets) || undefined,
682
+ [hook.draftTargets],
683
+ );
684
+
677
685
  useEffect(() => {
678
686
  function handler(e: MessageEvent<unknown>) {
679
687
  if (e.source !== iframeRef.current?.contentWindow) return;
@@ -1244,9 +1252,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1244
1252
  draftKey={`html:${hook.commentPopover.draftKey}`}
1245
1253
  onSubmit={hook.handleCommentSubmit}
1246
1254
  mentionSource={mentionSource}
1247
- // Pinpoint clicks open this composer directly, so it carries
1248
- // the surface's one-click "Looks good" (the global composer
1249
- // does not: a document-wide thumbs-up is not a thing).
1255
+ // A pinpoint click opens this composer directly (never the
1256
+ // selection toolbar), so it carries the surface's one-click 👍.
1257
+ // It also shows when the composer opens from a text selection's
1258
+ // Comment button: a harmless duplicate of the toolbar 👍.
1250
1259
  onQuickLookGood={hook.handleCommentLooksGood}
1251
1260
  onClose={hook.handleCommentClose}
1252
1261
  skillReferences
@@ -1255,6 +1264,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1255
1264
  kind: "selection",
1256
1265
  label: "Selected HTML",
1257
1266
  text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
1267
+ ...(askAIElementDetail ? { detail: askAIElementDetail } : {}),
1258
1268
  }}
1259
1269
  targetChips={targetChips}
1260
1270
  onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
@@ -980,6 +980,10 @@
980
980
 
981
981
  // Floating label naming the element under the cursor (like the markdown overlay).
982
982
  var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
983
+ // Media kinds name themselves only when the page gives no accessible name:
984
+ // unlike the semantic tags above, an aria-label on a <video> is a better
985
+ // label than "Video", and it keeps winning exactly as it did before.
986
+ var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
983
987
 
984
988
  var MAX_HOVER_LABEL = 40;
985
989
  function truncateLabel(text) {
@@ -1015,6 +1019,8 @@
1015
1019
  if (known) return known;
1016
1020
  var aria = el.getAttribute && el.getAttribute('aria-label');
1017
1021
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1022
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1023
+ if (media) return media;
1018
1024
  var role = el.getAttribute && el.getAttribute('role');
1019
1025
  if (role && role.trim()) return truncateLabel(role.trim());
1020
1026
  var tokens = meaningfulClassTokens(el);
@@ -2680,6 +2686,25 @@
2680
2686
  function ctxScrubUrl(value) {
2681
2687
  var v = String(value).trim();
2682
2688
  if (/^javascript:/i.test(v)) return null;
2689
+ // Srcdoc sessions serve a document's relative assets through the
2690
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
2691
+ // means nothing outside this session; the path after it is the author's
2692
+ // own, relative to the document, which is what an agent can find.
2693
+ if (!LIVE) {
2694
+ var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
2695
+ if (asset && asset[1]) {
2696
+ // The route percent-encodes each segment; show the name the author
2697
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
2698
+ // that decodes to ? or # (which the query scrub below would cut), keeps
2699
+ // the raw text.
2700
+ var authored = asset[1];
2701
+ try {
2702
+ var decoded = decodeURIComponent(authored);
2703
+ if (!/[?#]/.test(decoded)) authored = decoded;
2704
+ } catch (ex) {}
2705
+ v = authored + v.slice(asset[0].length);
2706
+ }
2707
+ }
2683
2708
  if (/^data:/i.test(v)) {
2684
2709
  var comma = v.indexOf(',');
2685
2710
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -2989,7 +3014,8 @@
2989
3014
  }
2990
3015
 
2991
3016
  // Shed order when over budget: the fields an agent can live without first.
2992
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3017
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3018
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
2993
3019
 
2994
3020
  function buildElementContext(el, maxBytes) {
2995
3021
  if (!el || el.nodeType !== 1) return null;
@@ -3016,6 +3042,12 @@
3016
3042
  if (heading) context.heading = heading;
3017
3043
  var component = ctxComponent(el);
3018
3044
  if (component) context.component = component;
3045
+ // The media file this element shows, resolved exactly as the composer
3046
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3047
+ // poster; see descRawSource), so the exported heading can name the file
3048
+ // even when the element has no usable src attribute.
3049
+ var sourceName = descSourceName(el);
3050
+ if (sourceName) context.sourceName = sourceName;
3019
3051
  if (LIVE) {
3020
3052
  context.page = { url: currentPageUrl() };
3021
3053
  var title = ctxCollapse(document.title, 200);
@@ -3128,11 +3160,95 @@
3128
3160
  }
3129
3161
  }
3130
3162
 
3131
- // Element text for an additional target: same capping and text-less
3132
- // description used by the primary element path in annotateElement.
3163
+ // --- Text-less element descriptions ---
3164
+ // A pinpoint on an element with no text quotes a description instead
3165
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3166
+ // from the next, so the description adds what identifies THIS element: its
3167
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3168
+ // file name. The source goes through the same scrub the element context
3169
+ // uses (no query, no fragment, data: reduced to its media type). Square
3170
+ // brackets become parentheses and double quotes single ones, so the whole
3171
+ // description stays one "[element: …]" token the export recognizes.
3172
+ var DESC_MAX_NAME = 80;
3173
+ var DESC_MAX_FILE = 60;
3174
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3175
+
3176
+ function descClean(value, max) {
3177
+ return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
3178
+ }
3179
+
3180
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3181
+ // first candidate's first word. A data: URL carries its own comma, so it is
3182
+ // taken up to whitespace instead.
3183
+ function descFirstSrcsetUrl(value) {
3184
+ var srcset = String(value || '').trim();
3185
+ if (!srcset) return '';
3186
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
3187
+ }
3188
+
3189
+ function descRawSource(el) {
3190
+ var tag = el.tagName;
3191
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3192
+ var attr = DESC_SRC_ATTR[tag];
3193
+ var src = '';
3194
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3195
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3196
+ else return '';
3197
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3198
+ var lazy = (el.getAttribute('data-src') || '').trim();
3199
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3200
+ if (!src && tag === 'IMG') {
3201
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3202
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3203
+ // no source at all; the first <source> candidate names the file.
3204
+ var picture = el.parentElement;
3205
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3206
+ var pictureSource = picture.querySelector('source[srcset]');
3207
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3208
+ }
3209
+ }
3210
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3211
+ var child = el.querySelector && el.querySelector('source[src]');
3212
+ if (child) src = (child.getAttribute('src') || '').trim();
3213
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3214
+ }
3215
+ return src;
3216
+ }
3217
+
3218
+ // The file name a source points at: "team.jpg" for
3219
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3220
+ function descSourceName(el) {
3221
+ var src = descRawSource(el);
3222
+ if (!src) return '';
3223
+ var scrubbed = ctxScrubUrl(src);
3224
+ if (!scrubbed) return '';
3225
+ if (/^data:/i.test(scrubbed)) {
3226
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3227
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3228
+ }
3229
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3230
+ var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
3231
+ var name = path.slice(path.lastIndexOf('/') + 1);
3232
+ try { name = decodeURIComponent(name); } catch (ex) {}
3233
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3234
+ return descClean(name, DESC_MAX_FILE);
3235
+ }
3236
+
3237
+ function describeTextlessElement(el, label) {
3238
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3239
+ var name = ctxName(el);
3240
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3241
+ if (name && name !== out) out += ' "' + name + '"';
3242
+ var file = descSourceName(el);
3243
+ if (file && file !== name) out += ' (' + file + ')';
3244
+ return out;
3245
+ }
3246
+
3247
+ // Element text for a pinpoint target (primary or additional): the
3248
+ // element's own text, or for a text-less element its description.
3133
3249
  function elementTargetText(el, label) {
3134
3250
  var text = capSelectionText((el.textContent || '').trim());
3135
- if (!text) text = capSelectionText('[element: ' + label + ']');
3251
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3136
3252
  return text;
3137
3253
  }
3138
3254
 
@@ -3371,10 +3487,10 @@
3371
3487
  if (!posted) clearPendingPin();
3372
3488
  return posted;
3373
3489
  }
3374
- var elText = capSelectionText((el.textContent || '').trim());
3375
- // Text-less elements (icon buttons, decorative chips, empty containers)
3376
- // are still annotatable: describe the element instead of quoting it.
3377
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3490
+ // Text-less elements (images, icon buttons, decorative chips, empty
3491
+ // containers) are still annotatable: describe the element instead of
3492
+ // quoting it, naming WHICH one it is where the page says so.
3493
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3378
3494
  var r = el.getBoundingClientRect();
3379
3495
  pendingSelection = { element: true };
3380
3496
  pendingRange = null;
@@ -1216,6 +1216,10 @@ export const BRIDGE_SCRIPT = `(function() {
1216
1216
 
1217
1217
  // Floating label naming the element under the cursor (like the markdown overlay).
1218
1218
  var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
1219
+ // Media kinds name themselves only when the page gives no accessible name:
1220
+ // unlike the semantic tags above, an aria-label on a <video> is a better
1221
+ // label than "Video", and it keeps winning exactly as it did before.
1222
+ var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
1219
1223
 
1220
1224
  var MAX_HOVER_LABEL = 40;
1221
1225
  function truncateLabel(text) {
@@ -1251,6 +1255,8 @@ export const BRIDGE_SCRIPT = `(function() {
1251
1255
  if (known) return known;
1252
1256
  var aria = el.getAttribute && el.getAttribute('aria-label');
1253
1257
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1258
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1259
+ if (media) return media;
1254
1260
  var role = el.getAttribute && el.getAttribute('role');
1255
1261
  if (role && role.trim()) return truncateLabel(role.trim());
1256
1262
  var tokens = meaningfulClassTokens(el);
@@ -2916,6 +2922,25 @@ export const BRIDGE_SCRIPT = `(function() {
2916
2922
  function ctxScrubUrl(value) {
2917
2923
  var v = String(value).trim();
2918
2924
  if (/^javascript:/i.test(v)) return null;
2925
+ // Srcdoc sessions serve a document's relative assets through the
2926
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
2927
+ // means nothing outside this session; the path after it is the author's
2928
+ // own, relative to the document, which is what an agent can find.
2929
+ if (!LIVE) {
2930
+ var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
2931
+ if (asset && asset[1]) {
2932
+ // The route percent-encodes each segment; show the name the author
2933
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
2934
+ // that decodes to ? or # (which the query scrub below would cut), keeps
2935
+ // the raw text.
2936
+ var authored = asset[1];
2937
+ try {
2938
+ var decoded = decodeURIComponent(authored);
2939
+ if (!/[?#]/.test(decoded)) authored = decoded;
2940
+ } catch (ex) {}
2941
+ v = authored + v.slice(asset[0].length);
2942
+ }
2943
+ }
2919
2944
  if (/^data:/i.test(v)) {
2920
2945
  var comma = v.indexOf(',');
2921
2946
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -3225,7 +3250,8 @@ export const BRIDGE_SCRIPT = `(function() {
3225
3250
  }
3226
3251
 
3227
3252
  // Shed order when over budget: the fields an agent can live without first.
3228
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3253
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3254
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
3229
3255
 
3230
3256
  function buildElementContext(el, maxBytes) {
3231
3257
  if (!el || el.nodeType !== 1) return null;
@@ -3252,6 +3278,12 @@ export const BRIDGE_SCRIPT = `(function() {
3252
3278
  if (heading) context.heading = heading;
3253
3279
  var component = ctxComponent(el);
3254
3280
  if (component) context.component = component;
3281
+ // The media file this element shows, resolved exactly as the composer
3282
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3283
+ // poster; see descRawSource), so the exported heading can name the file
3284
+ // even when the element has no usable src attribute.
3285
+ var sourceName = descSourceName(el);
3286
+ if (sourceName) context.sourceName = sourceName;
3255
3287
  if (LIVE) {
3256
3288
  context.page = { url: currentPageUrl() };
3257
3289
  var title = ctxCollapse(document.title, 200);
@@ -3364,11 +3396,95 @@ export const BRIDGE_SCRIPT = `(function() {
3364
3396
  }
3365
3397
  }
3366
3398
 
3367
- // Element text for an additional target: same capping and text-less
3368
- // description used by the primary element path in annotateElement.
3399
+ // --- Text-less element descriptions ---
3400
+ // A pinpoint on an element with no text quotes a description instead
3401
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3402
+ // from the next, so the description adds what identifies THIS element: its
3403
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3404
+ // file name. The source goes through the same scrub the element context
3405
+ // uses (no query, no fragment, data: reduced to its media type). Square
3406
+ // brackets become parentheses and double quotes single ones, so the whole
3407
+ // description stays one "[element: …]" token the export recognizes.
3408
+ var DESC_MAX_NAME = 80;
3409
+ var DESC_MAX_FILE = 60;
3410
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3411
+
3412
+ function descClean(value, max) {
3413
+ return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
3414
+ }
3415
+
3416
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3417
+ // first candidate's first word. A data: URL carries its own comma, so it is
3418
+ // taken up to whitespace instead.
3419
+ function descFirstSrcsetUrl(value) {
3420
+ var srcset = String(value || '').trim();
3421
+ if (!srcset) return '';
3422
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
3423
+ }
3424
+
3425
+ function descRawSource(el) {
3426
+ var tag = el.tagName;
3427
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3428
+ var attr = DESC_SRC_ATTR[tag];
3429
+ var src = '';
3430
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3431
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3432
+ else return '';
3433
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3434
+ var lazy = (el.getAttribute('data-src') || '').trim();
3435
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3436
+ if (!src && tag === 'IMG') {
3437
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3438
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3439
+ // no source at all; the first <source> candidate names the file.
3440
+ var picture = el.parentElement;
3441
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3442
+ var pictureSource = picture.querySelector('source[srcset]');
3443
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3444
+ }
3445
+ }
3446
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3447
+ var child = el.querySelector && el.querySelector('source[src]');
3448
+ if (child) src = (child.getAttribute('src') || '').trim();
3449
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3450
+ }
3451
+ return src;
3452
+ }
3453
+
3454
+ // The file name a source points at: "team.jpg" for
3455
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3456
+ function descSourceName(el) {
3457
+ var src = descRawSource(el);
3458
+ if (!src) return '';
3459
+ var scrubbed = ctxScrubUrl(src);
3460
+ if (!scrubbed) return '';
3461
+ if (/^data:/i.test(scrubbed)) {
3462
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3463
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3464
+ }
3465
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3466
+ var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
3467
+ var name = path.slice(path.lastIndexOf('/') + 1);
3468
+ try { name = decodeURIComponent(name); } catch (ex) {}
3469
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3470
+ return descClean(name, DESC_MAX_FILE);
3471
+ }
3472
+
3473
+ function describeTextlessElement(el, label) {
3474
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3475
+ var name = ctxName(el);
3476
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3477
+ if (name && name !== out) out += ' "' + name + '"';
3478
+ var file = descSourceName(el);
3479
+ if (file && file !== name) out += ' (' + file + ')';
3480
+ return out;
3481
+ }
3482
+
3483
+ // Element text for a pinpoint target (primary or additional): the
3484
+ // element's own text, or for a text-less element its description.
3369
3485
  function elementTargetText(el, label) {
3370
3486
  var text = capSelectionText((el.textContent || '').trim());
3371
- if (!text) text = capSelectionText('[element: ' + label + ']');
3487
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3372
3488
  return text;
3373
3489
  }
3374
3490
 
@@ -3607,10 +3723,10 @@ export const BRIDGE_SCRIPT = `(function() {
3607
3723
  if (!posted) clearPendingPin();
3608
3724
  return posted;
3609
3725
  }
3610
- var elText = capSelectionText((el.textContent || '').trim());
3611
- // Text-less elements (icon buttons, decorative chips, empty containers)
3612
- // are still annotatable: describe the element instead of quoting it.
3613
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3726
+ // Text-less elements (images, icon buttons, decorative chips, empty
3727
+ // containers) are still annotatable: describe the element instead of
3728
+ // quoting it, naming WHICH one it is where the page says so.
3729
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3614
3730
  var r = el.getBoundingClientRect();
3615
3731
  pendingSelection = { element: true };
3616
3732
  pendingRange = null;
@@ -477,8 +477,8 @@ export function useHtmlAnnotation({
477
477
  flashDraftTarget: (key: string) => void;
478
478
  /** Bumped after every target add/remove so the composer can refocus its textarea. */
479
479
  composerFocusToken: number;
480
- /** Composer one-click "Looks good": submits the hardcoded positive label
481
- * with the same anchor and multi-select targets a typed comment would carry. */
480
+ /** Composer one-click 👍: submits the hardcoded positive label with the
481
+ * same anchor and multi-select targets a typed comment would carry. */
482
482
  handleCommentLooksGood: () => void;
483
483
  /** Ids this module minted for locally created annotations (create-mark),
484
484
  * for the unanchored union: a minted id the host never listed is a
@@ -910,8 +910,14 @@ export function useHtmlAnnotation({
910
910
  [getOrCreateAnchor],
911
911
  );
912
912
 
913
- const handleCommentSubmit = useCallback(
914
- (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) => {
913
+ // One commit path for everything the pinpoint composer submits (a typed
914
+ // comment or the one-click 👍), so both carry the same anchor, element
915
+ // context and multi-select targets.
916
+ const commitComposerDraft = useCallback(
917
+ (
918
+ body: Pick<Annotation, "text"> &
919
+ Partial<Pick<Annotation, "images" | "mentions" | "isQuickLabel" | "quickLabelTip">>,
920
+ ) => {
915
921
  if (!enabledRef.current) return;
916
922
  // Prefer the text captured when the popover opened — it can't be clobbered by
917
923
  // a later selection change or clear while the user is composing the comment.
@@ -939,14 +945,10 @@ export function useHtmlAnnotation({
939
945
  startOffset: 0,
940
946
  endOffset: 0,
941
947
  type: AnnotationType.COMMENT,
942
- text: comment,
943
948
  originalText: text,
944
949
  author: getIdentity(),
945
950
  createdA: Date.now(),
946
- images,
947
- // Host capability: present only when a mentionSource was supplied AND
948
- // a token survived, so a comment without one is unchanged.
949
- ...(mentions && mentions.length > 0 ? { mentions } : {}),
951
+ ...body,
950
952
  htmlAnchor: pendingAnchorRef.current ?? undefined,
951
953
  elementContext: pendingContextRef.current ?? undefined,
952
954
  htmlAdditionalTargets: additionalTargets,
@@ -961,53 +963,32 @@ export function useHtmlAnnotation({
961
963
  [post],
962
964
  );
963
965
 
964
- // The composer's one-click "Looks good" (the restored thumbs-up for
965
- // comment-only surfaces, where pinpoint clicks land straight in the
966
- // composer and never see the selection toolbar). Mirrors
967
- // handleCommentSubmit — same anchor, same multi-select targets — but
968
- // emits the hardcoded positive label instead of typed prose.
969
- const handleCommentLooksGood = useCallback(() => {
970
- if (!enabledRef.current) return;
971
- const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
972
- if (!text) return;
973
-
974
- const targets = draftTargetsRef.current;
975
- const additionalTargets: HtmlAnnotationTarget[] | undefined =
976
- targets.length > 1
977
- ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
978
- label: t.label,
979
- text: t.text,
980
- anchor: t.anchor ?? undefined,
981
- ...(t.context ? { context: t.context } : {}),
982
- }))
983
- : undefined;
984
-
985
- const id = nextHtmlAnnId();
986
- mintedIdsRef.current.add(id);
987
- post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
988
- onAddRef.current?.({
989
- id,
990
- blockId: "",
991
- startOffset: 0,
992
- endOffset: 0,
993
- type: AnnotationType.COMMENT,
994
- text: THUMBS_UP_LABEL.text,
995
- originalText: text,
996
- isQuickLabel: true,
997
- quickLabelTip: THUMBS_UP_LABEL.tip,
998
- author: getIdentity(),
999
- createdA: Date.now(),
1000
- htmlAnchor: pendingAnchorRef.current ?? undefined,
1001
- elementContext: pendingContextRef.current ?? undefined,
1002
- htmlAdditionalTargets: additionalTargets,
1003
- });
966
+ const handleCommentSubmit = useCallback(
967
+ (comment: string, images?: ImageAttachment[], mentions?: readonly string[]) =>
968
+ commitComposerDraft({
969
+ text: comment,
970
+ images,
971
+ // Host capability: present only when a mentionSource was supplied AND
972
+ // a token survived, so a comment without one is unchanged.
973
+ ...(mentions && mentions.length > 0 ? { mentions } : {}),
974
+ }),
975
+ [commitComposerDraft],
976
+ );
1004
977
 
1005
- setCommentPopover(null);
1006
- setDraftTargets([]);
1007
- pendingTextRef.current = "";
1008
- pendingAnchorRef.current = null;
1009
- pendingContextRef.current = null;
1010
- }, [post]);
978
+ // The composer's one-click 👍. A pinpoint click opens this composer
979
+ // directly and never shows the selection toolbar, so without it a reviewer
980
+ // could not mark an element "looks good" in one click. Emits the same
981
+ // hardcoded positive label as the toolbar's 👍 (the only label comment-only
982
+ // surfaces may create), on the pinned element(s).
983
+ const handleCommentLooksGood = useCallback(
984
+ () =>
985
+ commitComposerDraft({
986
+ text: THUMBS_UP_LABEL.text,
987
+ isQuickLabel: true,
988
+ quickLabelTip: THUMBS_UP_LABEL.tip,
989
+ }),
990
+ [commitComposerDraft],
991
+ );
1011
992
 
1012
993
  const handleCommentClose = useCallback(() => {
1013
994
  post({ type: `${PREFIX}cancel-selection` });