@plannotator/ui 0.38.2 → 0.39.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/types.ts CHANGED
@@ -82,6 +82,7 @@ export interface Annotation {
82
82
  pageUrl?: string; // set only by live app annotate sessions: the page (pathname + search) the annotation was made on; restore filters to the current page and export groups by page
83
83
  inReplyTo?: string; // id of the annotation this one replies to; a reply inherits its parent's anchor, renders indented under it in the panel, and exports grouped under it. Additive: annotations without it render and export exactly as before.
84
84
  htmlAnchor?: HtmlElementAnchor; // raw-HTML pinpoint: serialized element anchor for reliable restoration
85
+ elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
85
86
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
86
87
  // web-highlighter metadata for cross-element selections
87
88
  startMeta?: AnnotationTextMeta;
@@ -124,6 +125,50 @@ export interface HtmlAnnotationTarget {
124
125
  text: string;
125
126
  /** Element anchor for restoration; absent when the bridge failed closed. */
126
127
  anchor?: HtmlElementAnchor;
128
+ /** Agent-facing element description (smaller budget than the primary's). */
129
+ context?: HtmlElementContext;
130
+ }
131
+
132
+ /**
133
+ * A bounded, agent-facing description of a pinpointed element, captured by the
134
+ * bridge at annotation time (only it can see the DOM). Purely descriptive:
135
+ * restore never reads it (that is `HtmlElementAnchor`'s job). It exists so the
136
+ * exported feedback can tell an agent working in the app's SOURCE which
137
+ * element the comment is about — identity (what it is), location (where it
138
+ * sits), and hooks (what to grep for) — without dumping the page. Every field
139
+ * is page-controlled and re-validated at the parent trust boundary
140
+ * (`parseHtmlElementContext`). Additive: annotations without one export
141
+ * exactly as before, and share links never carry it.
142
+ */
143
+ export interface HtmlElementContext {
144
+ tag: string;
145
+ id?: string;
146
+ /** Author classes, generated/hashed ones skipped; may end in "+N more". */
147
+ classes?: string[];
148
+ /** Ancestor path, e.g. `body > div#root > header.site-header > nav#site-nav`. */
149
+ path?: string;
150
+ /** Explicit `role` or the tag's implicit ARIA role. */
151
+ role?: string;
152
+ /** Accessible name: aria-label, aria-labelledby, alt, title, <label for>, own short text. */
153
+ name?: string;
154
+ /** Allowlisted attributes in a fixed order (href/src scrubbed of query and fragment). */
155
+ attrs?: Array<[string, string]>;
156
+ /** Rendered text (innerText), whitespace-collapsed, word-boundary truncated. */
157
+ text?: string;
158
+ /** Collapsed HTML skeleton: opening tag with allowlisted attributes, then children as bare tags. */
159
+ outline?: string;
160
+ /** Number of element children (after skipping script/style/template and viewer overlays). */
161
+ children?: number;
162
+ /** Viewport-relative bounding box plus the viewport it was seen at. */
163
+ rect?: { x: number; y: number; w: number; h: number; vw: number; vh: number };
164
+ /** Nearest enclosing landmark/region, e.g. `header.site-header "Primary"`. */
165
+ landmark?: string;
166
+ /** Nearest preceding heading, e.g. `h2 "Usage"`. */
167
+ heading?: string;
168
+ /** Nearest author component marker, e.g. `data-component=AppNav`. */
169
+ component?: string;
170
+ /** Live-app sessions only: the route the element was seen on and the page title. */
171
+ page?: { url: string; title?: string };
127
172
  }
128
173
 
129
174
  export type AlertKind = 'note' | 'tip' | 'warning' | 'caution' | 'important';
@@ -35,7 +35,6 @@ export interface DecisionPrimary {
35
35
  title: string; // tooltip / aria description
36
36
  tone: Exclude<DecisionTone, 'destructive'>;
37
37
  icon?: 'check' | 'send';
38
- count?: number; // rendered as the inline pill; omitted when 0
39
38
  /**
40
39
  * Platform self-approval (PR6, §3.4): rendered dimmed but NOT disabled.
41
40
  * The reason surfaces through the shared Tooltip + aria-describedby (the
@@ -87,7 +86,7 @@ export interface DecisionSpecInput {
87
86
  app: 'annotate' | 'review';
88
87
  /** Annotate: `gate`. Review: always true — review's primary decision IS approval. */
89
88
  gate: boolean;
90
- /** The count rendered in the pill and interpolated into labels. */
89
+ /** The annotation count interpolated into the menu's discard and note copy (never the primary label). */
91
90
  count: number;
92
91
  /**
93
92
  * Whether there is anything to send. Deliberately separate from `count`:
@@ -327,7 +326,6 @@ function buildFeedbackSpec(input: DecisionSpecInput, approvalFlow: boolean): Dec
327
326
  title: 'Send your feedback to the agent',
328
327
  tone: 'primary',
329
328
  icon: 'send',
330
- count: count > 0 ? count : undefined,
331
329
  },
332
330
  items,
333
331
  };
@@ -395,7 +393,6 @@ function buildPlatformSpec(input: DecisionSpecInput, platform: DecisionPlatformI
395
393
  title: `Post review to ${platform.label}`,
396
394
  tone: 'primary',
397
395
  icon: 'send',
398
- count: count > 0 ? count : undefined,
399
396
  },
400
397
  items: [
401
398
  {
package/utils/parser.ts CHANGED
@@ -1099,6 +1099,88 @@ const blockEndLine = (block: Block): number => {
1099
1099
 
1100
1100
  /** Resolve the source-line label for a single annotation.
1101
1101
  * Returns null for global comments, diff-view annotations, or missing blocks. */
1102
+ /** Defense in depth for page-controlled strings in agent-read feedback:
1103
+ * collapse whitespace (no injected markdown structure) and defuse any
1104
+ * backtick run that could close an inline code span or a fence. */
1105
+ const safeInline = (value: unknown, max = 200): string => {
1106
+ const collapsed = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
1107
+ const defused = collapsed.replace(/`+/g, "'");
1108
+ return defused.length > max ? `${defused.slice(0, max)}…` : defused;
1109
+ };
1110
+
1111
+ /** The synthesized quote the bridge posts for a text-less element
1112
+ * (`[element: Navigation]`): a placeholder, not something the agent can use. */
1113
+ const isElementPlaceholderQuote = (text: unknown): boolean =>
1114
+ typeof text === 'string' && /^\[element: [^\]]*\]$/.test(text.trim());
1115
+
1116
+ /** The heading line for a COMMENT entry. When the quote is the bridge's
1117
+ * text-less placeholder and the annotation carries element context, name the
1118
+ * element instead (`Feedback on the <nav> element — "Primary"`); every other
1119
+ * annotation keeps the quote line exactly as before. */
1120
+ const commentHeadingLine = (ann: any): string => {
1121
+ const context = ann?.elementContext;
1122
+ if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
1123
+ const tag = safeInline(context.tag, 32);
1124
+ const name = context.name ? safeInline(context.name, 120) : '';
1125
+ return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}`;
1126
+ }
1127
+ return `Feedback on: "${ann.originalText}"`;
1128
+ };
1129
+
1130
+ export interface ElementContextExportOptions {
1131
+ /** Emit the live-app route line. The grouped export already prints a
1132
+ * `## Page:` heading, so it passes false; a single copied entry passes true. */
1133
+ includeRoute?: boolean;
1134
+ }
1135
+
1136
+ /** The agent-facing element block for a raw-HTML / live-app pinpoint: a
1137
+ * fenced HTML skeleton (the one markdown construct whose interior cannot
1138
+ * become structure) plus the identity lines an agent greps for. Emits
1139
+ * nothing when the annotation carries no context, keeping every other
1140
+ * annotation's output byte-identical. */
1141
+ export const elementContextExportBlock = (ann: any, opts: ElementContextExportOptions = {}): string => {
1142
+ const context = ann?.elementContext;
1143
+ if (!context || typeof context !== 'object' || typeof context.tag !== 'string') return '';
1144
+ let block = '';
1145
+ if (typeof context.outline === 'string' && context.outline.trim()) {
1146
+ // Fence at 4 backticks; the boundary already defuses 3+ runs inside the
1147
+ // outline, and a 4-run here cannot be closed by anything the page wrote.
1148
+ const outline = context.outline.replace(/`{3,}/g, "'''").trim();
1149
+ block += `\n\`\`\`\`html\n${outline}\n\`\`\`\`\n`;
1150
+ } else {
1151
+ block += '\n';
1152
+ }
1153
+ const selector = ann?.htmlAnchor?.selector;
1154
+ if (typeof selector === 'string' && selector) block += `- **selector** \`${safeInline(selector, 300)}\`\n`;
1155
+ if (context.path) block += `- **path** \`${safeInline(context.path, 512)}\`\n`;
1156
+ const identity: string[] = [];
1157
+ if (context.role) identity.push(`**role** ${safeInline(context.role, 32)}`);
1158
+ if (context.name) identity.push(`**name** "${safeInline(context.name, 120)}"`);
1159
+ if (context.component) identity.push(`**component** \`${safeInline(context.component, 100)}\``);
1160
+ if (identity.length) block += `- ${identity.join(' · ')}\n`;
1161
+ if (Array.isArray(context.attrs) && context.attrs.length) {
1162
+ const attrs = context.attrs
1163
+ .filter((pair: unknown) => Array.isArray(pair) && pair.length === 2)
1164
+ .map((pair: [string, string]) => `${safeInline(pair[0], 40)}="${safeInline(pair[1], 120)}"`)
1165
+ .join(' ');
1166
+ if (attrs) block += `- **attrs** \`${attrs.replace(/`/g, "'")}\`\n`;
1167
+ }
1168
+ if (context.text) block += `- **text** "${safeInline(context.text, 300)}"\n`;
1169
+ if (opts.includeRoute && context.page && context.page.url) {
1170
+ const title = context.page.title ? ` — "${safeInline(context.page.title, 200)}"` : '';
1171
+ block += `- **route** \`${safeInline(context.page.url, 2048)}\`${title}\n`;
1172
+ }
1173
+ const r = context.rect;
1174
+ if (r && ['x', 'y', 'w', 'h', 'vw', 'vh'].every((k) => typeof r[k] === 'number' && Number.isFinite(r[k]))) {
1175
+ block += `- **box** ${r.x},${r.y} ${r.w}×${r.h} (viewport ${r.vw}×${r.vh})\n`;
1176
+ }
1177
+ const near: string[] = [];
1178
+ if (context.landmark) near.push(safeInline(context.landmark, 80));
1179
+ if (context.heading) near.push(`heading ${safeInline(context.heading, 130)}`);
1180
+ if (near.length) block += `- **near** ${near.join(' · ')}\n`;
1181
+ return block;
1182
+ };
1183
+
1102
1184
  /** Multi-target raw-HTML comments: list every ADDITIONAL element the one
1103
1185
  * comment covers (the primary target is already quoted as `originalText`),
1104
1186
  * labeled with the semantic hover label plus a short excerpt so the agent
@@ -1120,11 +1202,56 @@ const additionalTargetsExportBlock = (ann: any): string => {
1120
1202
  const raw = typeof target?.text === 'string' ? target.text : '';
1121
1203
  const excerpt = raw.replace(/\s+/g, ' ').trim();
1122
1204
  const clipped = excerpt.length > 120 ? `${excerpt.slice(0, 120)}…` : excerpt;
1123
- block += `- ${label}"${clipped}"\n`;
1205
+ // Element identity for the agent, one line per extra target: the
1206
+ // selector and the path (a full context block per target would swamp
1207
+ // the comment; the rest of the context stays persisted, not exported).
1208
+ const locators: string[] = [];
1209
+ const selector = target?.anchor?.selector;
1210
+ if (typeof selector === 'string' && selector) locators.push(`\`${safeInline(selector, 300)}\``);
1211
+ const path = target?.context?.path;
1212
+ if (typeof path === 'string' && path) locators.push(`\`${safeInline(path, 512)}\``);
1213
+ block += `- ${label}"${clipped}"${locators.length ? ` — ${locators.join(' · ')}` : ''}\n`;
1124
1214
  });
1125
1215
  return block;
1126
1216
  };
1127
1217
 
1218
+ /**
1219
+ * One annotation rendered as a standalone feedback entry (no number, no
1220
+ * document heading), for hosts that surface a single annotation to an agent.
1221
+ * The body is the same shape the full export emits for the entry, including
1222
+ * the element block, so a standalone entry never drifts from what Send
1223
+ * Feedback delivers; the live-app route line is included here because the
1224
+ * standalone entry has no `## Page:` heading above it. Plannotator's own
1225
+ * panel chrome does not use it.
1226
+ */
1227
+ export const exportAnnotationEntry = (ann: any, opts: ElementContextExportOptions = { includeRoute: true }): string => {
1228
+ let output = '';
1229
+ switch (ann?.type) {
1230
+ case 'DELETION':
1231
+ output += `Remove this\n\`\`\`\n${ann.originalText}\n\`\`\`\n`;
1232
+ break;
1233
+ case 'GLOBAL_COMMENT':
1234
+ output += `General feedback\n> ${ann.text}\n`;
1235
+ break;
1236
+ default:
1237
+ if (ann?.isQuickLabel) {
1238
+ output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
1239
+ if (ann.quickLabelTip) output += `> ${ann.quickLabelTip}\n`;
1240
+ } else {
1241
+ output += `${commentHeadingLine(ann)}\n> ${ann?.text ?? ''}\n`;
1242
+ }
1243
+ }
1244
+ output += elementContextExportBlock(ann, opts);
1245
+ output += additionalTargetsExportBlock(ann);
1246
+ if (Array.isArray(ann?.images) && ann.images.length > 0) {
1247
+ output += `**Attached images:**\n`;
1248
+ ann.images.forEach((img: ImageAttachment) => {
1249
+ output += `- [${img.name}] \`${img.path}\`\n`;
1250
+ });
1251
+ }
1252
+ return output;
1253
+ };
1254
+
1128
1255
  const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
1129
1256
  if (!ann.blockId || ann.type === 'GLOBAL_COMMENT') return null;
1130
1257
  if (typeof ann.blockId === 'string' && ann.blockId.startsWith('diff-block-')) return null;
@@ -1292,12 +1419,12 @@ export const exportAnnotations = (
1292
1419
 
1293
1420
  case 'COMMENT':
1294
1421
  if (ann.isQuickLabel) {
1295
- output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`;
1422
+ output += `[${ann.text}] ${commentHeadingLine(ann)}\n`;
1296
1423
  if (ann.quickLabelTip) {
1297
1424
  output += `> ${ann.quickLabelTip}\n`;
1298
1425
  }
1299
1426
  } else {
1300
- output += `Feedback on: "${ann.originalText}"\n`;
1427
+ output += `${commentHeadingLine(ann)}\n`;
1301
1428
  output += `> ${ann.text}\n`;
1302
1429
  }
1303
1430
  break;
@@ -1308,6 +1435,9 @@ export const exportAnnotations = (
1308
1435
  break;
1309
1436
  }
1310
1437
 
1438
+ // Raw-HTML / live-app pinpoints describe their element for the agent
1439
+ // (the grouped export's `## Page:` heading already carries the route).
1440
+ output += elementContextExportBlock(ann, { includeRoute: false });
1311
1441
  // Multi-target raw-HTML comments list every additional covered element.
1312
1442
  output += additionalTargetsExportBlock(ann);
1313
1443
 
@@ -1408,7 +1538,7 @@ export const exportLinkedDocAnnotations = (
1408
1538
  break;
1409
1539
 
1410
1540
  case 'COMMENT':
1411
- output += `Feedback on: "${ann.originalText}"\n`;
1541
+ output += `${commentHeadingLine(ann)}\n`;
1412
1542
  output += `> ${ann.text}\n`;
1413
1543
  break;
1414
1544
 
@@ -1418,6 +1548,7 @@ export const exportLinkedDocAnnotations = (
1418
1548
  break;
1419
1549
  }
1420
1550
 
1551
+ output += elementContextExportBlock(ann, { includeRoute: false });
1421
1552
  // Multi-target raw-HTML comments list every additional covered element.
1422
1553
  output += additionalTargetsExportBlock(ann);
1423
1554