@kontourai/survey 8.1.1 → 8.2.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.
@@ -14,9 +14,11 @@
14
14
  */
15
15
  import { type ServerReviewSessionRecord } from "../review-workbench/server-review-session.js";
16
16
  import { type ReviewQueueBinding } from "../review-workbench/queue-binding.js";
17
- import type { ReviewWorkbenchResult } from "../review-workbench/review-workbench.js";
17
+ import type { ReviewWorkbenchResult, ReviewQueuePresentation } from "../review-workbench/review-workbench.js";
18
18
  import type { ReviewSessionEvent } from "../review-resource.js";
19
19
  export interface ReviewConsoleServerOptions {
20
+ /** Producer-owned display hints; independent of queue binding and application authority. */
21
+ readonly presentation?: ReviewQueuePresentation;
20
22
  /** Absolute path to the session JSON file. */
21
23
  readonly sessionPath: string;
22
24
  /** TCP port. Defaults to 0 (OS-assigned). */
@@ -286,6 +286,7 @@ body {
286
286
  color: var(--k-text, #eef3f8);
287
287
  font-size: 13px;
288
288
  }
289
+ .console-save-status[data-state="success"] { border-color: var(--k-positive); }
289
290
  .console-save-status[hidden] { display: none; }
290
291
  #review-workbench {
291
292
  flex: 1;
@@ -325,7 +326,7 @@ body {
325
326
  </button>
326
327
  </div>
327
328
  </header>
328
- ${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle">Send review to workflow</button></div>' : ''}
329
+ ${canApply ? '<div style="display:flex;justify-content:flex-end;padding:8px 16px"><button type="button" id="apply-review" class="theme-toggle" disabled>Send review to workflow</button></div>' : ''}
329
330
  <div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
330
331
  <main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
331
332
  <script type="module">
@@ -342,11 +343,13 @@ import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionNa
342
343
  // stored session, re-mounts from it and tells the reviewer once.
343
344
  let saveChain = Promise.resolve();
344
345
 
345
- function showSaveStatus(message) {
346
+ function showSaveStatus(message, state = "error") {
346
347
  const el = document.getElementById("console-save-status");
347
348
  if (!el) return;
348
349
  if (message) {
349
350
  el.textContent = message;
351
+ el.dataset.state = state;
352
+ el.setAttribute("role", state === "success" ? "status" : "alert");
350
353
  el.hidden = false;
351
354
  } else {
352
355
  el.textContent = "";
@@ -366,7 +369,9 @@ function createConsoleEventStore(baseRevision) {
366
369
 
367
370
  async function refused(message) {
368
371
  abandoned = true;
369
- showSaveStatus(message);
372
+ saveFailed = true;
373
+ updateApplyButton();
374
+ showSaveStatus(message + " Reload before sending the review, or retry the change.");
370
375
  await fetchAndMount();
371
376
  }
372
377
 
@@ -387,9 +392,11 @@ function createConsoleEventStore(baseRevision) {
387
392
  return;
388
393
  }
389
394
  if (res.ok) {
390
- const body = await res.json().catch(() => ({}));
391
- if (typeof body.revision === "string") { revision = body.revision; currentRevision = revision; }
395
+ const body = await res.json();
396
+ if (typeof body.revision !== "string") throw new Error("Missing saved revision");
397
+ revision = body.revision; currentRevision = revision;
392
398
  persistedCount = localEvents.length;
399
+ saveFailed = false;
393
400
  showSaveStatus("");
394
401
  return;
395
402
  }
@@ -404,9 +411,18 @@ function createConsoleEventStore(baseRevision) {
404
411
  return {
405
412
  // The mount state is already replayed from the stored log.
406
413
  load: () => undefined,
407
- save: (_session, events) => {
414
+ save: (session, events) => {
415
+ currentState = session;
416
+ stateGeneration++;
417
+ pendingSaveCount++;
418
+ updateApplyButton();
408
419
  const localEvents = [...events];
409
- saveChain = saveChain.then(() => persist(localEvents));
420
+ saveChain = saveChain.then(() => persist(localEvents)).catch(async () => {
421
+ await refused("Your last change was not saved. The console reloaded the stored session.");
422
+ }).finally(() => {
423
+ pendingSaveCount--;
424
+ updateApplyButton();
425
+ });
410
426
  pendingSave = saveChain;
411
427
  return saveChain;
412
428
  },
@@ -418,9 +434,19 @@ let currentRevision = null;
418
434
  let applying = false;
419
435
  let wasApplied = false;
420
436
  let pendingSave = Promise.resolve();
437
+ let pendingSaveCount = 0;
438
+ let saveFailed = false;
439
+ let currentState = null;
440
+ let stateGeneration = 0;
441
+ let fetchGeneration = 0;
442
+ function updateApplyButton() {
443
+ if (!applyButton) return;
444
+ const complete = currentState?.items?.length > 0 && currentState.items.every(item => currentState.decisionsByItemName[item.metadata.name] !== undefined);
445
+ applyButton.disabled = applying || wasApplied || pendingSaveCount > 0 || saveFailed || !currentRevision || !complete;
446
+ }
421
447
  const applyButton = document.getElementById("apply-review");
422
448
  applyButton?.addEventListener("click", async () => {
423
- if (applying || !currentRevision) return;
449
+ if (applyButton.disabled || applying || !currentRevision) return;
424
450
  applying = true;
425
451
  applyButton.disabled = true;
426
452
  try {
@@ -430,31 +456,41 @@ applyButton?.addEventListener("click", async () => {
430
456
  body: JSON.stringify({ baseRevision: currentRevision }),
431
457
  });
432
458
  const body = await response.json();
433
- showSaveStatus(response.ok ? body.application.summary : body.error);
459
+ showSaveStatus(response.ok ? body.application.summary : body.error, response.ok ? "success" : "error");
434
460
  await fetchAndMount();
435
- } catch { showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
436
- finally { applying = false; applyButton.disabled = wasApplied || !currentRevision; }
461
+ } catch { saveFailed = true; showSaveStatus("The review could not reach the workflow. Reload before trying again."); }
462
+ finally { applying = false; updateApplyButton(); }
437
463
  });
438
464
 
439
465
  async function fetchAndMount() {
466
+ const fetchBasis = ++fetchGeneration;
467
+ const stateBasis = stateGeneration;
440
468
  try {
441
469
  const res = await fetch("/api/session");
470
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
442
471
  if (!res.ok) {
443
472
  const body = await res.json().catch(() => ({}));
473
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
474
+ currentRevision = null;
475
+ updateApplyButton();
444
476
  showSaveStatus(body.error || ("The session could not be loaded (HTTP " + res.status + ")."));
445
477
  const root = document.getElementById("review-workbench");
446
478
  if (root) root.innerHTML = "";
447
479
  return;
448
480
  }
449
481
  const data = await res.json();
450
- const { snapshot, events, revision, extractionImport } = data;
482
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
483
+ const { snapshot, events, revision, extractionImport, presentation } = data;
451
484
  currentRevision = revision;
452
485
  wasApplied = Boolean(data.producerApplication);
453
- if (applyButton) applyButton.disabled = applying || wasApplied;
486
+ if (data.producerApplication) showSaveStatus(data.producerApplication.summary, "success");
454
487
  const state = events && events.length > 0
455
488
  ? replayReviewSessionEvents(snapshot, events)
456
489
  : snapshot;
457
490
 
491
+ currentState = state;
492
+ updateApplyButton();
493
+
458
494
  // Preserve active item selection across reloads
459
495
  if (activeItemName && state.items && state.items.some(i => i.metadata.name === activeItemName)) {
460
496
  state.activeItemName = activeItemName;
@@ -463,8 +499,14 @@ async function fetchAndMount() {
463
499
  const root = document.getElementById("review-workbench");
464
500
  if (!root) return;
465
501
 
466
- mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
502
+ mountReviewWorkbench(root, state, { hideApply: Boolean(applyButton), queuePresentation: presentation, eventStore: createConsoleEventStore(revision), ...(extractionImport ? { extractionImport } : {}) });
503
+ if (wasApplied) root.querySelectorAll("button[data-item-name], input[data-item-name], textarea[data-item-name], select[data-item-name]").forEach(control => { control.disabled = true; });
467
504
  } catch (err) {
505
+ if (fetchBasis !== fetchGeneration || stateBasis !== stateGeneration) return;
506
+ currentRevision = null;
507
+ currentState = null;
508
+ updateApplyButton();
509
+ showSaveStatus("The session could not be loaded. Reload before sending the review.");
468
510
  console.error("[console] Mount error:", err);
469
511
  }
470
512
  }
@@ -486,7 +528,7 @@ function connectSse() {
486
528
  if (label) label.textContent = "live";
487
529
  });
488
530
  sse.addEventListener("update", () => {
489
- fetchAndMount();
531
+ if (pendingSaveCount === 0 && !applying) fetchAndMount();
490
532
  });
491
533
  sse.addEventListener("error", () => {
492
534
  if (dot) dot.classList.add("disconnected");
@@ -664,6 +706,7 @@ export async function startReviewConsoleServer(options) {
664
706
  events: content.events,
665
707
  revision: reviewSessionRevision(content.events),
666
708
  state: currentSessionState(content.snapshot, content.events),
709
+ ...(options.presentation ? { presentation: options.presentation } : {}),
667
710
  queueAttestation: attestation.state,
668
711
  ...(content.producerApplication ? { producerApplication: content.producerApplication } : {}),
669
712
  ...(content.extractionImport !== undefined ? { extractionImport: content.extractionImport } : {}),
@@ -1781,6 +1781,17 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
1781
1781
  }
1782
1782
  }
1783
1783
 
1784
+ .survey-workbench-embed .review-context{
1785
+ margin: var(--k-space-4);
1786
+ padding: var(--k-space-4);
1787
+ border: var(--k-border-thin) solid var(--k-line-strong);
1788
+ border-radius: var(--k-radius-sm);
1789
+ background: var(--k-panel-raised);
1790
+ color: var(--k-text);
1791
+ white-space: pre-wrap;
1792
+ overflow-wrap: anywhere;
1793
+ }
1794
+
1784
1795
 
1785
1796
  .survey-workbench-embed {
1786
1797
  overflow: hidden;
@@ -1778,6 +1778,17 @@
1778
1778
  }
1779
1779
  }
1780
1780
 
1781
+ .survey-workbench-embed .review-context{
1782
+ margin: var(--k-space-4);
1783
+ padding: var(--k-space-4);
1784
+ border: var(--k-border-thin) solid var(--k-line-strong);
1785
+ border-radius: var(--k-radius-sm);
1786
+ background: var(--k-panel-raised);
1787
+ color: var(--k-text);
1788
+ white-space: pre-wrap;
1789
+ overflow-wrap: anywhere;
1790
+ }
1791
+
1781
1792
 
1782
1793
  .survey-workbench-embed {
1783
1794
  overflow: hidden;
@@ -100,7 +100,20 @@ export interface PersistentReviewSessionEventStoreOptions {
100
100
  readonly persist: (request: ReviewSessionPersistenceRequest) => Promise<ReviewSessionPersistenceResult | void>;
101
101
  readonly onStatusChange?: (state: ReviewSessionPersistenceState) => void;
102
102
  }
103
+ /** Serializable producer-owned display hints. Never alter canonical items, events or apply authority. */
104
+ export interface ReviewQueuePresentation {
105
+ readonly title?: string;
106
+ readonly context?: string;
107
+ readonly items?: Readonly<Record<string, {
108
+ readonly title?: string;
109
+ readonly kind?: "acknowledgment" | "choice";
110
+ readonly candidateLabels?: Readonly<Record<string, string>>;
111
+ }>>;
112
+ }
103
113
  export interface MountReviewWorkbenchOptions {
114
+ readonly queuePresentation?: ReviewQueuePresentation;
115
+ /** A host that owns submission can hide the local apply/export control. */
116
+ readonly hideApply?: boolean;
104
117
  readonly eventStore?: ReviewSessionEventStore;
105
118
  readonly presentationAdapter?: ReviewPresentationAdapter;
106
119
  /**
@@ -238,6 +251,8 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
238
251
  export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
239
252
  readonly presentationAdapter?: ReviewPresentationAdapter;
240
253
  readonly queueView?: Partial<ReviewQueueView>;
254
+ readonly queuePresentation?: ReviewQueuePresentation;
255
+ readonly hideApply?: boolean;
241
256
  /** See {@link MountReviewWorkbenchOptions.extractionImport}. */
242
257
  readonly extractionImport?: ExtractionEnvelopeImport | ExtractionEnvelopeImportResult;
243
258
  /** See {@link MountReviewWorkbenchOptions.fieldStates}. */
@@ -135,10 +135,12 @@ function buildDecisionCardAuthorizing(state, adapter) {
135
135
  * Renders the decision card's prompt for a review item and decision.
136
136
  *
137
137
  * It is rebuilt from the item when the decision is built, not captured from
138
- * what a reviewer's screen showed: it records what the card states for this
139
- * item, with the same presentation adapter, target label and value text.
138
+ * what a reviewer's screen showed: it records a deterministic semantic basis
139
+ * from the canonical item, decision and presentation adapter. Queue display
140
+ * hints (title, context, action labels) are not a verbatim prompt capture and
141
+ * do not change this server-reconstructable authorizing basis.
140
142
  *
141
- * Format mirrors the review question shown in the workbench header:
143
+ * The current/proposed semantic form is:
142
144
  * "For {target}, decide whether {proposed} should replace {current}."
143
145
  * followed by the selected decision label so the block is self-contained.
144
146
  */
@@ -523,7 +525,7 @@ export function createPersistentReviewSessionEventStore(options) {
523
525
  }
524
526
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
525
527
  const session = queueSessionFromStartState(state);
526
- return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates);
528
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates, options.queuePresentation, options.hideApply);
527
529
  }
528
530
  /**
529
531
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -611,7 +613,7 @@ function isEmptyValue(value) {
611
613
  }
612
614
  const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
613
615
  const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
614
- function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates) {
616
+ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
615
617
  // A queue that does not match its stored extraction import is not reviewed:
616
618
  // its items could hide or alter what the source said.
617
619
  if (attestation.state === "diverges") {
@@ -641,7 +643,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
641
643
  <div class="top">
642
644
  <div class="subj">
643
645
  <p class="eyebrow">Proposed changes</p>
644
- <h1>${escapeHtml(headerTitle)}</h1>
646
+ <h1>${escapeHtml(queuePresentation?.title ?? headerTitle)}</h1>
645
647
  </div>
646
648
  </div>
647
649
  <div class="meta">
@@ -650,9 +652,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
650
652
  <div class="progress">
651
653
  <div class="bar"><i style="width:${progressPct}%"></i></div>
652
654
  <span class="ptext"><b data-testid="decided-count">${decidedCount}</b> of <b>${totalCount}</b> decided</span>
653
- <button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
655
+ ${hideApply ? "" : `<button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>`}
654
656
  </div>
655
657
  </header>
658
+ ${queuePresentation?.context ? `<section class="review-context" data-testid="review-context" aria-label="Review context">${escapeHtml(queuePresentation.context)}</section>` : ""}
656
659
  ${fieldStates ? renderFieldStatesHtml(fieldStates, session) : ""}
657
660
  <nav class="queue-controls" aria-label="Review queue navigation">
658
661
  <label>
@@ -673,7 +676,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
673
676
  </div>` : ""}
674
677
  </nav>
675
678
  <div class="fields" data-testid="review-fields">
676
- ${window.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")
679
+ ${window.items.map((item) => renderFieldCard(item, session, presentationAdapter, queuePresentation?.items?.[item.metadata.name])).join("")
677
680
  || '<p class="queue-empty">No review fields match the current search and disposition.</p>'}
678
681
  </div>
679
682
  ${renderFooterTally(session)}
@@ -691,7 +694,7 @@ function queueDispositionOptions(selected) {
691
694
  ];
692
695
  return options.map(([value, label]) => `<option value="${value}"${selected === value ? " selected" : ""}>${label}</option>`).join("");
693
696
  }
694
- function renderFieldCard(item, session, presentationAdapter) {
697
+ function renderFieldCard(item, session, presentationAdapter, hints) {
695
698
  const decision = session.decisionsByItemName[item.metadata.name];
696
699
  const state = fieldCardState(item, decision);
697
700
  const decided = decision !== undefined;
@@ -703,6 +706,10 @@ function renderFieldCard(item, session, presentationAdapter) {
703
706
  const conflict = proposedCandidates.length > 1;
704
707
  const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
705
708
  const presentation = buildReviewItemPresentation(item, presentationAdapter);
709
+ const title = hints?.title ?? presentation.targetLabel;
710
+ const acknowledgment = hints?.kind === "acknowledgment" && proposed !== undefined;
711
+ const choice = hints?.kind === "choice";
712
+ const statusLabel = decision === "could-not-confirm" ? "Could not confirm" : choice ? (decided ? "Action recorded" : "Choose an action") : acknowledgment ? (decided ? "Reviewed" : "Needs review") : chipLabel(state, current !== undefined && !isEmptyValue(current.value), conflict ? proposedCandidates.length : undefined);
706
713
  const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
707
714
  const kind = hasCurrentValue ? "Update" : "New";
708
715
  const keepLabel = hasCurrentValue ? "Keep current" : conflict ? "Reject all values" : "Leave unset";
@@ -734,26 +741,28 @@ function renderFieldCard(item, session, presentationAdapter) {
734
741
  <div class="stripe"></div>
735
742
  <div class="fbody">
736
743
  <div class="frow1">
737
- <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
744
+ <span class="fname">${escapeHtml(title)}</span>
738
745
  <span class="fkind">${kind}</span>
739
- <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
746
+ <span class="chip ${state} push" data-testid="field-chip">${statusLabel}</span>
740
747
  </div>
741
748
  ${proposed
742
749
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
743
750
  : proposedCandidates.length > 1
744
- ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
751
+ ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name], choice ? hints : undefined)
745
752
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
746
753
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter, isScoreBlind(session)) : ""}
747
754
  ${renderExtractionImportNotes(presentation)}
748
755
  ${isScoreBlind(session) ? "" : renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
756
+ ${choice ? renderReviewerNote(item, session) : ""}
749
757
  <div class="decide">
750
- ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
751
- ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
752
- <label class="wrong">
758
+ ${acknowledgment || keepDecision === undefined || (choice && !current) ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(choice && current ? hints?.candidateLabels?.[current.id] ?? keepLabel : keepLabel)}</button>`}
759
+ ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">${acknowledgment ? "Mark reviewed" : choice ? escapeHtml(hints?.candidateLabels?.[proposed.id] ?? proposedPresentationText) : "Use proposed"}</button>
760
+ ${acknowledgment || choice ? "" : `<label class="wrong">
753
761
  <input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
754
762
  Suggestion was wrong
755
763
  </label>
756
- <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
764
+ <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>`}
765
+ ${acknowledgment || choice ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}` : ""}
757
766
  ${conflict ? `<button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
758
767
  </div>
759
768
  <!--
@@ -765,10 +774,10 @@ function renderFieldCard(item, session, presentationAdapter) {
765
774
  -->
766
775
  <span class="derr" data-testid="decision-error" role="alert" hidden></span>
767
776
  <div class="decided">
768
- <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue, conflict ? proposedCandidates.length : undefined)}</span>
777
+ <span class="chip ${state}" data-testid="decided-chip">${statusLabel}</span>
769
778
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
770
779
  </div>
771
- ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
780
+ ${renderAuditDetails(item, current, proposed, session, presentationAdapter, choice)}
772
781
  </div>
773
782
  </section>
774
783
  `;
@@ -782,23 +791,24 @@ function renderFieldCard(item, session, presentationAdapter) {
782
791
  * one value. Proposals excluded at import are not candidates: they are listed
783
792
  * by {@link renderExtractionImportNotes} and cannot be chosen.
784
793
  */
785
- function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
794
+ function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId, hints) {
786
795
  const chose = decision === "select-proposed";
787
796
  const values = candidates.map((candidate) => {
788
797
  const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
798
+ const actionLabel = hints?.candidateLabels?.[candidate.id] ?? text;
789
799
  const excerpt = candidate.locator?.excerpt;
790
800
  const chosen = chose && candidate.id === selectedCandidateId;
791
801
  const mark = chose
792
802
  ? ` <span class="choice-tag ${chosen ? "chosen" : "not-chosen"}" data-testid="choice-tag">${chosen ? "Chosen" : "Not chosen"}</span>`
793
803
  : "";
794
804
  const control = decision === undefined
795
- ? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}">Use this value</button>`
805
+ ? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="${hints ? escapeHtml(actionLabel) : `Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}`}">${hints ? escapeHtml(actionLabel) : "Use this value"}</button>`
796
806
  : "";
797
807
  return `<li data-testid="conflicting-value" data-candidate-id="${escapeHtml(candidate.id)}"${chose ? ` data-chosen="${chosen ? "true" : "false"}"` : ""}>
798
808
  <span class="conflict-text"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}${mark}</span>${control}
799
809
  </li>`;
800
810
  }).join("");
801
- const intro = decision === undefined
811
+ const intro = hints ? (decision === undefined ? "Choose an action. Add a reviewer note when a correction or explanation is needed." : "Action recorded.") : decision === undefined
802
812
  ? `${candidates.length} different values were proposed for this field. Use the value the source supports, reject them all, or mark the field Could not confirm with a reason.`
803
813
  : chose
804
814
  ? `${candidates.length} different values were proposed for this field. One was chosen; ${candidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
@@ -1066,7 +1076,10 @@ function renderConfidenceMeter(confidence) {
1066
1076
  * here is secondary detail tucked behind one `<details>` toggle, kept off the
1067
1077
  * primary review surface.
1068
1078
  */
1069
- function renderAuditDetails(item, current, proposed, session, presentationAdapter) {
1079
+ function renderReviewerNote(item, session) {
1080
+ return `<label class="note-field"><span class="field-label">Reviewer note</span><textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(session.notesByItemName[item.metadata.name] ?? "")}</textarea></label>`;
1081
+ }
1082
+ function renderAuditDetails(item, current, proposed, session, presentationAdapter, noteOnCard = false) {
1070
1083
  const decision = session.decisionsByItemName[item.metadata.name];
1071
1084
  const note = session.notesByItemName[item.metadata.name] ?? "";
1072
1085
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
@@ -1091,10 +1104,7 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1091
1104
  <details class="audit-details" data-testid="audit-details">
1092
1105
  <summary>Audit details</summary>
1093
1106
  <div class="audit-body">
1094
- <label class="note-field">
1095
- <span class="field-label">Reviewer note</span>
1096
- <textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
1097
- </label>
1107
+ ${noteOnCard ? "" : renderReviewerNote(item, session)}
1098
1108
  ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
1099
1109
  ${renderProducerFeedbackTags(item)}
1100
1110
  <dl class="field-stack compact">
@@ -1459,7 +1469,7 @@ function createReviewWorkbenchController(root, startState, options) {
1459
1469
  currentSession: () => session,
1460
1470
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
1461
1471
  presentationAdapter: options.presentationAdapter,
1462
- renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates),
1472
+ renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates, options.queuePresentation, options.hideApply),
1463
1473
  setDecision,
1464
1474
  clearDecision,
1465
1475
  updateReviewerNote,
@@ -1479,8 +1489,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
1479
1489
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1480
1490
  return `${keyPrefix}:${itemNames}`;
1481
1491
  }
1482
- function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates) {
1483
- root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates);
1492
+ function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation, fieldStates, queuePresentation, hideApply) {
1493
+ root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined), fieldStates, queuePresentation, hideApply);
1484
1494
  bindFieldCardInteractions(root, controller);
1485
1495
  bindApplyButton(root, controller);
1486
1496
  bindClampToggles(root);
@@ -1242,3 +1242,14 @@ pre[data-testid="decision-payload"] {
1242
1242
  transition: none !important;
1243
1243
  }
1244
1244
  }
1245
+
1246
+ .review-context {
1247
+ margin: var(--k-space-4);
1248
+ padding: var(--k-space-4);
1249
+ border: var(--k-border-thin) solid var(--k-line-strong);
1250
+ border-radius: var(--k-radius-sm);
1251
+ background: var(--k-panel-raised);
1252
+ color: var(--k-text);
1253
+ white-space: pre-wrap;
1254
+ overflow-wrap: anywhere;
1255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -93,7 +93,7 @@
93
93
  "devDependencies": {
94
94
  "@kontourai/flow-agents": "5.10.0",
95
95
  "@kontourai/traverse": "^3.0.1",
96
- "@kontourai/ui": "^1.18.0",
96
+ "@kontourai/ui": "^1.20.0",
97
97
  "@playwright/test": "^1.62.0",
98
98
  "@types/node": "^26.1.2",
99
99
  "typescript": "^7.0.2"