@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/HANDOFF.md +3 -2
- package/README.md +1 -1
- package/components/DecisionControl.tsx +0 -10
- package/components/html-viewer/bridge-script.asset.js +440 -3
- package/components/html-viewer/bridge-script.ts +440 -3
- package/components/html-viewer/useHtmlAnnotation.ts +175 -1
- package/package.json +1 -1
- package/styles.css +1 -1
- package/types.ts +45 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/parser.ts +135 -4
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';
|
package/utils/decisionSpec.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}]
|
|
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 +=
|
|
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 +=
|
|
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
|
|