@kontourai/survey 2.2.4 → 2.3.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.
@@ -41,7 +41,77 @@ export function buildExtractionInspectorModel(input) {
41
41
  candidates[index].alignment = "excerpt-mismatch";
42
42
  }
43
43
  }
44
- return { sources, candidates };
44
+ return { sources, candidates: bindHighlightElementIds(candidates) };
45
+ }
46
+ const HIGHLIGHT_ID_PREFIX = "highlight-";
47
+ /**
48
+ * Assigns every candidate the element id its source highlight will carry.
49
+ *
50
+ * Done here, over the whole model, rather than by a pure per-id derivation:
51
+ * sanitizing a candidate id to an HTML identifier is lossy (`a:b.c` and `a:b-c`
52
+ * both reduce to `a-b-c`), and two anchors sharing an id silently resolve a
53
+ * host's link to the wrong sentence. Today's candidate-id scheme happens not to
54
+ * produce that collision — the entry index always separates two sources — so
55
+ * the disambiguating suffix is defensive rather than a fix for a reachable bug.
56
+ * It is here because uniqueness is now a published promise
57
+ * ({@link ExtractionInspectorCandidate.highlightElementId}) that must survive a
58
+ * later change to the key scheme rather than depend on one.
59
+ */
60
+ function bindHighlightElementIds(candidates) {
61
+ const used = new Set();
62
+ return candidates.map((candidate) => ({
63
+ ...candidate,
64
+ highlightElementId: uniqueHighlightElementId(candidate.id, used),
65
+ }));
66
+ }
67
+ /**
68
+ * Derives an element id not already taken.
69
+ *
70
+ * `used` holds finished element ids — prefix included — and so does the value
71
+ * returned, because the two have to be the same form to be comparable. They were
72
+ * not: uniqueness was checked on the bare token while the set held prefixed ids,
73
+ * so a published `highlight-alpha` did not stop a candidate with id `alpha` from
74
+ * deriving `highlight-alpha` and mounting a second element under it. A host's
75
+ * `href` then resolved to whichever came first.
76
+ */
77
+ function uniqueHighlightElementId(candidateId, used) {
78
+ const base = `${HIGHLIGHT_ID_PREFIX}${safeId(candidateId)}`;
79
+ let elementId = base;
80
+ for (let suffix = 2; used.has(elementId); suffix += 1)
81
+ elementId = `${base}-${suffix}`;
82
+ used.add(elementId);
83
+ return elementId;
84
+ }
85
+ /** The inspector's own candidate-list id, paired 1:1 with a highlight anchor. Not public. */
86
+ function candidateElementId(highlightElementId) {
87
+ return `candidate-${highlightElementId.slice(HIGHLIGHT_ID_PREFIX.length)}`;
88
+ }
89
+ /**
90
+ * The element id each candidate's anchor will carry, for one mount.
91
+ *
92
+ * A model from {@link buildExtractionInspectorModel} already carries one per
93
+ * candidate and it is used verbatim — rewriting a published id is precisely the
94
+ * drift this contract exists to prevent. A hand-authored model may omit some or
95
+ * all of them; those get an id derived here, seeded with the published ones so a
96
+ * derived id can never collide with one a host is already linking to.
97
+ *
98
+ * Keyed on the candidate OBJECT, not on `candidate.id`. The builder's ids are
99
+ * structurally unique, but this function exists for models a caller assembled,
100
+ * where nothing enforces that — and an id-keyed map silently collapsed two such
101
+ * candidates into one entry, handing the second's derived id to the first and
102
+ * discarding the very `highlightElementId` a host was already linking to. Object
103
+ * identity cannot collide, whatever the data says.
104
+ */
105
+ function resolveHighlightElementIds(model) {
106
+ const used = new Set();
107
+ for (const candidate of model.candidates)
108
+ if (candidate.highlightElementId)
109
+ used.add(candidate.highlightElementId);
110
+ const resolved = new Map();
111
+ for (const candidate of model.candidates) {
112
+ resolved.set(candidate, candidate.highlightElementId ?? uniqueHighlightElementId(candidate.id, used));
113
+ }
114
+ return resolved;
45
115
  }
46
116
  function assertResolvedArtifact(artifact) {
47
117
  if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
@@ -164,7 +234,9 @@ export function exportExtractionInspector(model, options = {}) {
164
234
  return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
165
235
  redaction: { preparedTextIncluded: options.includePreparedText === true, excerptsIncluded: options.includeExcerpts === true },
166
236
  sources: model.sources.map(({ artifactText, message: _message, ...source }) => ({ ...source, preparedText: options.includePreparedText ? artifactText ?? null : "[redacted]" })),
167
- candidates: model.candidates.map((candidate) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
237
+ // highlightElementId is a DOM binding for a live inspector, not extraction
238
+ // evidence — it stays out of the canonical export (and out of its digest).
239
+ candidates: model.candidates.map(({ highlightElementId: _highlightElementId, ...candidate }) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
168
240
  } });
169
241
  }
170
242
  export function mountExtractionInspector(container, model, options = {}) {
@@ -172,6 +244,8 @@ export function mountExtractionInspector(container, model, options = {}) {
172
244
  ? Math.min(Number(options.pageSize), 500)
173
245
  : 100;
174
246
  let page = 0;
247
+ const highlightIds = resolveHighlightElementIds(model);
248
+ const highlightIdFor = (candidate) => highlightIds.get(candidate);
175
249
  const root = document.createElement("section");
176
250
  root.className = "extraction-inspector";
177
251
  root.setAttribute("aria-label", "Source-linked extraction inspector");
@@ -188,7 +262,7 @@ export function mountExtractionInspector(container, model, options = {}) {
188
262
  page = Math.min(page, pageCount - 1);
189
263
  const start = page * pageSize;
190
264
  const visible = matching.slice(start, start + pageSize);
191
- list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="candidate-${safeId(c.id)}" data-candidate-id="${escapeHtml(c.id)}" aria-controls="highlight-${safeId(c.id)}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
265
+ list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="${escapeHtml(candidateElementId(highlightIdFor(c)))}" data-candidate-id="${escapeHtml(c.id)}" data-highlight-element-id="${escapeHtml(highlightIdFor(c))}" aria-controls="${escapeHtml(highlightIdFor(c))}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
192
266
  resultCount.textContent = matching.length === 0 ? "No matching candidates" : `${start + 1}–${Math.min(start + pageSize, matching.length)} of ${matching.length}`;
193
267
  pageLabel.textContent = `Page ${page + 1} of ${pageCount}`;
194
268
  pageLabel.hidden = pageCount === 1;
@@ -197,7 +271,7 @@ export function mountExtractionInspector(container, model, options = {}) {
197
271
  previous.disabled = page === 0;
198
272
  next.disabled = page >= pageCount - 1;
199
273
  postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
200
- sourcesRoot.innerHTML = model.sources.map(s => { const candidates = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, candidates)}</pre></div>`; }).join("");
274
+ sourcesRoot.innerHTML = model.sources.map(s => { const anchored = model.candidates.filter(c => c.sourceKey === s.key); const marked = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `${anchored.map(c => anchorHtml(c, highlightIdFor(c))).join("")}<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, anchored, marked, highlightIdFor)}</pre></div>`; }).join("");
201
275
  };
202
276
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
203
277
  filters[key] = select.value;
@@ -209,50 +283,187 @@ export function mountExtractionInspector(container, model, options = {}) {
209
283
  delete filters.query; page = 0; render(); });
210
284
  previous.addEventListener("click", () => { page = Math.max(0, page - 1); render(); list.querySelector("button")?.focus(); });
211
285
  next.addEventListener("click", () => { page += 1; render(); list.querySelector("button")?.focus(); });
212
- const activateCandidate = (id) => { const candidate = model.candidates.find(c => c.id === id); if (!candidate)
213
- return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: id, reviewItemName: candidate.reviewItemName } })); };
214
- root.addEventListener("click", event => { const candidate = event.target.closest("button[data-candidate-id]"); if (candidate) {
286
+ // The event carries the resolved binding as well as the identity: a listener
287
+ // that wants to focus the highlight must never reconstruct the id, and the
288
+ // first-party custom element is the reference implementation of that rule.
289
+ const candidateFor = (highlightElementId) => model.candidates.find(c => highlightIdFor(c) === highlightElementId);
290
+ const activateCandidate = (highlightElementId) => { const candidate = candidateFor(highlightElementId); if (!candidate)
291
+ return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: candidate.id, reviewItemName: candidate.reviewItemName, highlightElementId } })); };
292
+ const clearFilters = () => {
293
+ for (const key of Object.keys(filters))
294
+ delete filters[key];
295
+ root.querySelectorAll("select[data-filter]").forEach(select => { select.value = ""; });
296
+ const search = root.querySelector('input[data-filter="query"]');
297
+ if (search)
298
+ search.value = "";
299
+ };
300
+ /**
301
+ * Brings a candidate's row onto the mounted page so it can be focused.
302
+ *
303
+ * Anchors exist for every candidate, so a highlight can be activated while its
304
+ * candidate row is on another page or filtered out entirely. Returning focus
305
+ * to a row that is not mounted would silently do nothing, so page to it —
306
+ * clearing the filters first if they are what is hiding it.
307
+ */
308
+ const revealCandidateRow = (highlightElementId) => {
309
+ const candidate = candidateFor(highlightElementId);
310
+ if (!candidate)
311
+ return undefined;
312
+ let index = filterExtractionInspectorCandidates(model, filters).indexOf(candidate);
313
+ if (index < 0) {
314
+ clearFilters();
315
+ index = model.candidates.indexOf(candidate);
316
+ }
317
+ if (index < 0)
318
+ return undefined;
319
+ page = Math.floor(index / pageSize);
320
+ render();
321
+ return candidate;
322
+ };
323
+ const returnToCandidate = (highlightElementId) => {
324
+ if (revealCandidateRow(highlightElementId))
325
+ root.querySelector(`#${CSS.escape(candidateElementId(highlightElementId))}`)?.focus();
326
+ };
327
+ /**
328
+ * Follows a host's `href="#<highlightElementId>"`.
329
+ *
330
+ * The anchor that id names always exists, but a candidate off the current page
331
+ * or excluded by a filter has no painted highlight, so the link would land the
332
+ * reader on an invisible marker in the middle of the source text. Page to the
333
+ * candidate instead, so the highlight it points at is actually painted, and put
334
+ * focus on it.
335
+ */
336
+ const followHighlightFragment = () => {
337
+ const fragment = typeof location === "undefined" ? "" : location.hash.slice(1);
338
+ if (!fragment)
339
+ return;
340
+ if (!candidateFor(fragment))
341
+ return;
342
+ revealCandidateRow(fragment);
343
+ // `~=` matches one whitespace-separated token: a mark painted over a span
344
+ // shared by several candidates lists all of them, and this is the one the
345
+ // reader asked for. Record it, so activating that mark returns to the
346
+ // candidate they arrived by rather than to whichever is listed first.
347
+ const mark = root.querySelector(`[data-highlight-return-to~="${CSS.escape(fragment)}"]`);
348
+ if (!mark)
349
+ return;
350
+ mark.dataset.highlightArrivedBy = fragment;
351
+ mark.focus();
352
+ mark.scrollIntoView({ block: "center" });
353
+ };
354
+ const onHashChange = () => followHighlightFragment();
355
+ if (typeof window !== "undefined")
356
+ window.addEventListener("hashchange", onHashChange);
357
+ /** The candidate a shared mark returns to: the one the reader arrived by, else the first it covers. */
358
+ const returnTargetOf = (mark) => mark.dataset.highlightArrivedBy ?? mark.dataset.highlightReturnTo.split(" ")[0];
359
+ root.addEventListener("click", event => { const candidate = event.target.closest("button[data-highlight-element-id]"); if (candidate) {
215
360
  event.preventDefault();
216
361
  event.stopPropagation();
217
- activateCandidate(candidate.dataset.candidateId);
362
+ activateCandidate(candidate.dataset.highlightElementId);
218
363
  return;
219
- } const highlight = event.target.closest("button[data-highlight-candidate-id]"); if (highlight) {
364
+ } const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
220
365
  event.preventDefault();
221
366
  event.stopPropagation();
222
- root.querySelector(`#candidate-${CSS.escape(safeId(highlight.dataset.highlightCandidateId))}`)?.focus();
367
+ returnToCandidate(returnTargetOf(highlight));
223
368
  } });
224
- root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (!button)
369
+ root.addEventListener("keydown", event => { const highlight = event.target.closest("[data-highlight-return-to]"); if (highlight) {
370
+ if (event.key !== "Enter" && event.key !== " ")
371
+ return;
372
+ event.preventDefault();
373
+ event.stopPropagation();
374
+ returnToCandidate(returnTargetOf(highlight));
375
+ return;
376
+ } const button = event.target.closest("button[data-highlight-element-id]"); if (!button)
225
377
  return; if (event.key === "Enter" || event.key === " ") {
226
378
  event.preventDefault();
227
379
  event.stopPropagation();
228
- activateCandidate(button.dataset.candidateId);
380
+ activateCandidate(button.dataset.highlightElementId);
229
381
  return;
230
382
  } if (event.key === "ArrowDown" || event.key === "ArrowUp") {
231
383
  event.preventDefault();
232
- const buttons = [...list.querySelectorAll("button[data-candidate-id]")];
384
+ const buttons = [...list.querySelectorAll("button[data-highlight-element-id]")];
233
385
  const index = buttons.indexOf(button);
234
386
  buttons[event.key === "ArrowDown" ? Math.min(index + 1, buttons.length - 1) : Math.max(index - 1, 0)]?.focus();
235
387
  } });
236
388
  render();
237
- return () => root.remove();
389
+ followHighlightFragment();
390
+ return () => { if (typeof window !== "undefined")
391
+ window.removeEventListener("hashchange", onHashChange); root.remove(); };
238
392
  }
239
- function renderSource(text, candidates) {
393
+ /**
394
+ * Renders the prepared text with a return anchor for every candidate and a
395
+ * `<mark>` for the ones on the current page.
396
+ *
397
+ * The two sets are deliberately different. `marked` follows the candidate
398
+ * list's paging and filtering, so the painting tracks what the reviewer is
399
+ * looking at. `anchored` is every candidate the source has, so that the element
400
+ * id published on the model resolves whatever page the reviewer is on and
401
+ * whatever they have typed into the filter box — the anchors carry the contract,
402
+ * the marks carry the view.
403
+ */
404
+ function renderSource(text, anchored, marked, highlightIdFor) {
240
405
  const boundaries = new Set([0, text.length]);
241
- candidates.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
242
- const points = [...boundaries].sort((a, b) => a - b);
406
+ anchored.forEach(c => boundaries.add(c.start));
407
+ marked.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
408
+ const points = [...boundaries].filter(point => point >= 0 && point <= text.length).sort((a, b) => a - b);
243
409
  let html = "";
244
410
  const starts = new Map();
245
- candidates.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
411
+ anchored.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
412
+ const emitted = new Set();
246
413
  for (let i = 0; i < points.length - 1; i++) {
247
414
  const start = points[i], end = points[i + 1];
248
- for (const c of starts.get(start) ?? [])
249
- html += `<button type="button" class="highlight-anchor" id="highlight-${safeId(c.id)}" data-highlight-candidate-id="${escapeHtml(c.id)}" aria-label="Source highlight for ${escapeHtml(c.field)}; activate to return to candidate"></button>`;
415
+ for (const c of starts.get(start) ?? []) {
416
+ emitted.add(c);
417
+ html += anchorHtml(c, highlightIdFor(c));
418
+ }
250
419
  const segment = escapeHtml(text.slice(start, end));
251
- const active = candidates.filter(c => c.start < end && c.end > start);
252
- html += active.length ? `<mark aria-label="Highlighted for ${active.map(c => escapeHtml(c.field)).join(", ")}">${segment}</mark>` : segment;
420
+ const active = marked.filter(c => c.start < end && c.end > start);
421
+ html += active.length ? markHtml(segment, active, highlightIdFor) : segment;
253
422
  }
423
+ // A span starting at or past the end of the prepared text has no segment to
424
+ // lead; its anchor still has to exist, or its published id resolves nowhere.
425
+ for (const c of anchored)
426
+ if (!emitted.has(c))
427
+ html += anchorHtml(c, highlightIdFor(c));
254
428
  return html;
255
429
  }
430
+ /**
431
+ * The link target a host's `href` resolves to: an empty, inert span at the start
432
+ * of the candidate's span.
433
+ *
434
+ * Deliberately NOT a control. It exists for every candidate in the model, which
435
+ * is what makes the published id resolve unconditionally — but a thing that
436
+ * exists per candidate must cost nothing to a keyboard or screen-reader user, and
437
+ * as a `<button>` it did: 600 candidates put 600 invisible tab stops in sequence,
438
+ * several of them stacked together in the non-grounded postures. It was also a
439
+ * one-pixel pointer target once its accidental user-agent chrome was removed —
440
+ * an affordance in name only, at any size, because nothing about it was visible.
441
+ *
442
+ * Returning to the candidate is now the job of the thing a reader can actually
443
+ * see and aim at: the highlight itself. See {@link markHtml}.
444
+ */
445
+ function anchorHtml(candidate, highlightElementId) {
446
+ return `<span class="highlight-anchor" id="${escapeHtml(highlightElementId)}" data-highlight-candidate-id="${escapeHtml(candidate.id)}"></span>`;
447
+ }
448
+ /**
449
+ * The painted highlight, and the control that returns to its candidate.
450
+ *
451
+ * The highlight is the only part of this surface a reader can see, so it is the
452
+ * target: full phrase width rather than a sliver, discoverable because it is
453
+ * already visibly marked, and one tab stop per painted highlight rather than one
454
+ * per candidate in the model. `data-highlight-return-to` is separate from the
455
+ * anchor's `data-highlight-candidate-id` so the documented reverse lookup keeps
456
+ * resolving to exactly one element.
457
+ */
458
+ function markHtml(segment, active, highlightIdFor) {
459
+ const fields = active.map(c => escapeHtml(c.field)).join(", ");
460
+ // Every candidate the mark covers, not just the first. Two claims over one
461
+ // span is ordinary in extraction, and naming both in the label while binding
462
+ // only one left the others with a highlight that could not be navigated to or
463
+ // returned from — the label said two, the binding said one.
464
+ const bindings = active.map(c => escapeHtml(highlightIdFor(c))).join(" ");
465
+ return `<mark class="source-highlight" role="button" tabindex="0" data-highlight-return-to="${bindings}" aria-label="Highlighted for ${fields}; activate to return to candidate">${segment}</mark>`;
466
+ }
256
467
  function formatContext(candidate) {
257
468
  const context = [];
258
469
  if (candidate.pdfRegion) {
@@ -1,3 +1,4 @@
1
+ import { findSoleCandidateById } from "../review-resource.js";
1
2
  import { formatValue } from "./review-surface-preview.js";
2
3
  export function buildReviewItemPresentation(item, adapter = {}) {
3
4
  const context = { item };
@@ -34,7 +35,7 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
34
35
  const targetLabel = item && itemContext
35
36
  ? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
36
37
  : humanizeIdentifier(target);
37
- const selectedCandidate = item?.spec.candidates.find((candidate) => candidate.role === result.selectedCandidateRole || candidate.id === result.selectedCandidateId);
38
+ const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
38
39
  return {
39
40
  result,
40
41
  item,
@@ -53,6 +54,32 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
53
54
  : [{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" }],
54
55
  };
55
56
  }
57
+ /**
58
+ * The candidate a result selected, resolved by its complete identity.
59
+ *
60
+ * `find(role === … || id === …)` returned whichever candidate matched EITHER
61
+ * half, so on an item carrying a repeated candidate id it could return a
62
+ * different candidate than the result names — presenting one candidate's value
63
+ * against another's decision, which is exactly what it did.
64
+ *
65
+ * The id is the identity; {@link findSoleCandidateById} makes it fail closed
66
+ * rather than pick a winner when it is ambiguous, and the declared role has to
67
+ * agree when the result states one. Falling back to the role alone is kept for
68
+ * results that carry no id, and requires the role to be unambiguous too.
69
+ */
70
+ function selectedCandidateForResult(item, result) {
71
+ if (result.selectedCandidateId) {
72
+ const candidate = findSoleCandidateById(item, result.selectedCandidateId);
73
+ if (candidate && (result.selectedCandidateRole === undefined || candidate.role === result.selectedCandidateRole)) {
74
+ return candidate;
75
+ }
76
+ }
77
+ if (result.selectedCandidateRole === undefined) {
78
+ return undefined;
79
+ }
80
+ const byRole = item.spec.candidates.filter((candidate) => candidate.role === result.selectedCandidateRole);
81
+ return byRole.length === 1 ? byRole[0] : undefined;
82
+ }
56
83
  export function humanizeIdentifier(value) {
57
84
  return value
58
85
  .replace(/[_-]+/g, " ")
@@ -91,6 +91,17 @@ export declare function reviewSessionSummary(session: ReviewQueueSessionState):
91
91
  * would invent a prior value, with provenance, that the source never had.
92
92
  */
93
93
  export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boolean): ReviewWorkbenchDecision | undefined;
94
+ /**
95
+ * The candidate a workbench decision applies to.
96
+ *
97
+ * Selection is by role, but the id this returns is what every caller makes
98
+ * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
99
+ * replay expectation. So the id has to name exactly one candidate before it
100
+ * leaves here. Guarding the render path alone let the workbench emit an
101
+ * undecidable decision through the export path and then present a different
102
+ * candidate's value against it; this is the shared selector all of those go
103
+ * through, which is why the check belongs here rather than at each of them.
104
+ */
94
105
  export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
95
106
  /**
96
107
  * The value that should actually be applied for a decision: the reviewer's inline
@@ -1,6 +1,6 @@
1
1
  import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { reviewResourceApiVersion, } from "../../src/review-resource.js";
3
+ import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
4
4
  export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
5
  export const defaultReviewSessionName = "review-workbench-session";
6
6
  export const workbenchDecisionDefinitions = {
@@ -148,12 +148,24 @@ export function keepActionDecision(item, flaggedWrong) {
148
148
  }
149
149
  return "keep-current";
150
150
  }
151
+ /**
152
+ * The candidate a workbench decision applies to.
153
+ *
154
+ * Selection is by role, but the id this returns is what every caller makes
155
+ * durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
156
+ * replay expectation. So the id has to name exactly one candidate before it
157
+ * leaves here. Guarding the render path alone let the workbench emit an
158
+ * undecidable decision through the export path and then present a different
159
+ * candidate's value against it; this is the shared selector all of those go
160
+ * through, which is why the check belongs here rather than at each of them.
161
+ */
151
162
  export function candidateForDecision(item, decision) {
152
163
  const definition = workbenchDecisionDefinitions[decision];
153
164
  const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
154
165
  if (!candidate) {
155
166
  throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
156
167
  }
168
+ assertSoleCandidateId(item, candidate.id);
157
169
  return candidate;
158
170
  }
159
171
  /**
@@ -1,3 +1,4 @@
1
+ import { findSoleCandidateById } from "../review-resource.js";
1
2
  export function buildSurfaceProjectionPreview(item, decision, presentationAdapter = {}) {
2
3
  const selectedCandidate = selectedPreviewCandidate(item, decision);
3
4
  if (!selectedCandidate || !decision) {
@@ -38,7 +39,7 @@ function selectedPreviewCandidate(item, decision) {
38
39
  if (!decision?.spec.candidateId) {
39
40
  return undefined;
40
41
  }
41
- const candidate = item.spec.candidates.find((entry) => entry.id === decision.spec.candidateId);
42
+ const candidate = findSoleCandidateById(item, decision.spec.candidateId);
42
43
  if (!candidate) {
43
44
  throw new Error(`ReviewItem ${item.metadata.name} has no candidate ${decision.spec.candidateId}.`);
44
45
  }
@@ -202,11 +202,37 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
202
202
  .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
203
203
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
204
204
  .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
205
- .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
205
+ .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
206
206
  .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
207
207
  .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
208
- .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
209
- .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
208
+ /* An inert link target, one per candidate in the model, so a host's
209
+ \`href="#<highlightElementId>"\` always resolves. It is not a control and must
210
+ not behave like one: no size, no tab stop, nothing painted. */
211
+ .survey-workbench-embed .highlight-anchor{
212
+ display: inline;
213
+ width: 0;
214
+ height: 0;
215
+ overflow: hidden;
216
+ }
217
+
218
+ /* The highlight IS the return control — the only part of this surface a reader
219
+ can see, so it is the thing to aim at. Full phrase width, already visibly
220
+ marked, one tab stop per painted highlight. */
221
+ .survey-workbench-embed .source-highlight{
222
+ cursor: pointer;
223
+ border-radius: 2px;
224
+ /* Vertical padding on an inline box grows the hit area without moving the
225
+ line box, and the prepared text's line-height below leaves room for it, so
226
+ the target reaches ~24px tall without lines overlapping each other. The
227
+ phrase itself supplies the width. */
228
+ padding: 5px 3px;
229
+ margin: 0 -3px;
230
+ }
231
+
232
+ .survey-workbench-embed .source-highlight:focus-visible{
233
+ outline: 3px solid var(--k-active);
234
+ outline-offset: 1px;
235
+ }
210
236
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
211
237
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
212
238
 
@@ -639,6 +665,22 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
639
665
  display: none;
640
666
  }
641
667
 
668
+ /* Why a decision was refused, next to the button that refused it. The input
669
+ that satisfies the precondition can be inside the collapsed audit accordion,
670
+ so the message cannot live only with the input (kontourai/survey#208). */
671
+ .survey-workbench-embed .derr{
672
+ display: block;
673
+ margin-top: 6px;
674
+ text-align: right;
675
+ font-size: 12px;
676
+ font-weight: 600;
677
+ color: var(--k-negative);
678
+ }
679
+
680
+ .survey-workbench-embed .derr[hidden]{
681
+ display: none;
682
+ }
683
+
642
684
  /* confidence + provenance */
643
685
 
644
686
  .survey-workbench-embed .prov{
@@ -1080,14 +1122,6 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
1080
1122
  text-transform: uppercase;
1081
1123
  }
1082
1124
 
1083
- .survey-workbench-embed .preview-section.is-neutral{
1084
- background: var(--k-raised);
1085
- }
1086
-
1087
- .survey-workbench-embed .preview-section.is-neutral h3{
1088
- color: var(--k-faint);
1089
- }
1090
-
1091
1125
  .survey-workbench-embed .reference-details{
1092
1126
  margin-top: 8px;
1093
1127
  }
@@ -298,7 +298,30 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
298
298
  if (this.#session.activeItemName !== item.metadata.name)
299
299
  this.#session = { ...this.#session, activeItemName: item.metadata.name };
300
300
  this.#remount();
301
- queueMicrotask(() => this.#root.querySelector(`#highlight-${CSS.escape(detail.candidateId.replace(/[^a-zA-Z0-9_-]/g, "-"))}`)?.focus());
301
+ // Both lookups below are published contracts, used as published.
302
+ // Reconstructing the element id from candidateId — which this did — meant
303
+ // carrying a copy of Survey's private id sanitizer and ignoring the
304
+ // collision suffix that makes the id unique, which is exactly what the
305
+ // consumer guide tells embedders not to do.
306
+ const { highlightElementId } = detail;
307
+ if (!highlightElementId)
308
+ return;
309
+ queueMicrotask(() => {
310
+ // Routed on the highlight element id, which is unique by construction.
311
+ // candidateId is the candidate's own identity and a caller-authored
312
+ // model may repeat it, so a `[data-…="<candidateId>"]` lookup can select
313
+ // a different candidate's highlight — confidently wrong, on the surface
314
+ // whose job is showing which span a value came from. `~=` matches one
315
+ // whitespace-separated token, so a mark over a shared span is found too.
316
+ const highlight = this.#root.querySelector(`[data-highlight-return-to~="${CSS.escape(highlightElementId)}"]`);
317
+ if (highlight) {
318
+ highlight.focus();
319
+ return;
320
+ }
321
+ // Off the current page there is nothing painted; bring the link target
322
+ // into view instead of leaving the reader where they were.
323
+ this.#root.querySelector(`#${CSS.escape(highlightElementId)}`)?.scrollIntoView({ block: "center" });
324
+ });
302
325
  });
303
326
  }
304
327
  /** The review queue session to display. Setting this property re-mounts the workbench. */
@@ -199,11 +199,37 @@
199
199
  .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
200
200
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
201
201
  .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
202
- .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
202
+ .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
203
203
  .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
204
204
  .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
205
- .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
206
- .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
205
+ /* An inert link target, one per candidate in the model, so a host's
206
+ `href="#<highlightElementId>"` always resolves. It is not a control and must
207
+ not behave like one: no size, no tab stop, nothing painted. */
208
+ .survey-workbench-embed .highlight-anchor{
209
+ display: inline;
210
+ width: 0;
211
+ height: 0;
212
+ overflow: hidden;
213
+ }
214
+
215
+ /* The highlight IS the return control — the only part of this surface a reader
216
+ can see, so it is the thing to aim at. Full phrase width, already visibly
217
+ marked, one tab stop per painted highlight. */
218
+ .survey-workbench-embed .source-highlight{
219
+ cursor: pointer;
220
+ border-radius: 2px;
221
+ /* Vertical padding on an inline box grows the hit area without moving the
222
+ line box, and the prepared text's line-height below leaves room for it, so
223
+ the target reaches ~24px tall without lines overlapping each other. The
224
+ phrase itself supplies the width. */
225
+ padding: 5px 3px;
226
+ margin: 0 -3px;
227
+ }
228
+
229
+ .survey-workbench-embed .source-highlight:focus-visible{
230
+ outline: 3px solid var(--k-active);
231
+ outline-offset: 1px;
232
+ }
207
233
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
208
234
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
209
235
 
@@ -636,6 +662,22 @@
636
662
  display: none;
637
663
  }
638
664
 
665
+ /* Why a decision was refused, next to the button that refused it. The input
666
+ that satisfies the precondition can be inside the collapsed audit accordion,
667
+ so the message cannot live only with the input (kontourai/survey#208). */
668
+ .survey-workbench-embed .derr{
669
+ display: block;
670
+ margin-top: 6px;
671
+ text-align: right;
672
+ font-size: 12px;
673
+ font-weight: 600;
674
+ color: var(--k-negative);
675
+ }
676
+
677
+ .survey-workbench-embed .derr[hidden]{
678
+ display: none;
679
+ }
680
+
639
681
  /* confidence + provenance */
640
682
 
641
683
  .survey-workbench-embed .prov{
@@ -1077,14 +1119,6 @@
1077
1119
  text-transform: uppercase;
1078
1120
  }
1079
1121
 
1080
- .survey-workbench-embed .preview-section.is-neutral{
1081
- background: var(--k-raised);
1082
- }
1083
-
1084
- .survey-workbench-embed .preview-section.is-neutral h3{
1085
- color: var(--k-faint);
1086
- }
1087
-
1088
1122
  .survey-workbench-embed .reference-details{
1089
1123
  margin-top: 8px;
1090
1124
  }
@@ -2,7 +2,8 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
2
2
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
5
+ export { reviewAuditRowKeys, type ReviewAuditRowKey } from "./audit-rows.js";
6
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type BuiltExtractionInspectorCandidate, type BuiltExtractionInspectorModel, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
6
7
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
7
8
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
8
9
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";