@kontourai/survey 2.2.3 → 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.
@@ -4,9 +4,11 @@ import { candidateForDecision, keepActionDecision, buildReviewSessionEvent, buil
4
4
  import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
5
5
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
6
6
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
7
- import { reviewResourceApiVersion, } from "../review-resource.js";
7
+ import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
9
  import { humanizeIdentifier } from "./review-presentation.js";
10
+ import { createAuditFactTrace, } from "./audit-rows.js";
11
+ export { reviewAuditRowKeys } from "./audit-rows.js";
10
12
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
11
13
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
12
14
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
@@ -251,7 +253,7 @@ function buildReviewApplyActionContext(result, itemByName, issues) {
251
253
  };
252
254
  }
253
255
  function matchingSelectedCandidate(item, result) {
254
- const candidate = item.spec.candidates.find((entry) => entry.id === result.selectedCandidateId);
256
+ const candidate = findSoleCandidateById(item, result.selectedCandidateId);
255
257
  return candidate
256
258
  && candidate.role === result.selectedCandidateRole
257
259
  && structuralEqual(candidate.value, result.selectedValue)
@@ -660,6 +662,14 @@ function renderFieldCard(item, session, presentationAdapter) {
660
662
  </label>
661
663
  <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
662
664
  </div>
665
+ <!--
666
+ A control's precondition message belongs where the control is. The
667
+ reason "Could not confirm" needs lives in the collapsed audit
668
+ accordion, so writing the failure only into that textarea's validation
669
+ bubble put it somewhere the reviewer could not see at the moment they
670
+ clicked, and the button read as permanently dead (kontourai/survey#208).
671
+ -->
672
+ <span class="derr" data-testid="decision-error" role="alert" hidden></span>
663
673
  <div class="decided">
664
674
  <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue)}</span>
665
675
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
@@ -886,6 +896,10 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
886
896
  };
887
897
  const reviewDecisionPayload = buildReviewDecision(state);
888
898
  const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
899
+ // Seeded with what the card face already shows, so the audit surface never
900
+ // reprints it. Passed down through every section: the sections render in DOM
901
+ // order, so "first placement wins" is also "highest-context placement wins".
902
+ const trace = createAuditFactTrace(proposed ? [{ of: proposed.id, property: "locator.excerpt", value: proposed.locator?.excerpt }] : []);
889
903
  return `
890
904
  <details class="audit-details" data-testid="audit-details">
891
905
  <summary>Audit details</summary>
@@ -897,17 +911,17 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
897
911
  ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
898
912
  ${renderProducerFeedbackTags(item)}
899
913
  <dl class="field-stack compact">
900
- ${current ? fieldItem("Current candidate ID", current.id) : ""}
901
- ${proposed ? fieldItem("Proposed candidate ID", proposed.id) : ""}
902
- ${proposed ? fieldItem("Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
903
- ${proposed ? fieldItem("Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
904
- ${proposed?.locator ? fieldItem("Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
905
- ${proposed?.extraction.model ? fieldItem("Model", proposed.extraction.model) : ""}
906
- ${proposed ? fieldItem("Extractor", proposed.extraction.extractor ?? "unknown") : ""}
907
- ${proposed ? fieldItem("Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
914
+ ${current ? fieldItem("current-candidate-id", "Current candidate ID", current.id) : ""}
915
+ ${proposed ? fieldItem("proposed-candidate-id", "Proposed candidate ID", proposed.id) : ""}
916
+ ${proposed ? fieldItem("claim-id", "Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
917
+ ${proposed ? placementItem(trace, { of: proposed.id, property: "source.sourceId" }, "raw-source-id", "Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
918
+ ${proposed?.locator ? fieldItem("locator", "Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
919
+ ${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
920
+ ${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
921
+ ${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
908
922
  </dl>
909
923
  ${preview
910
- ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
924
+ ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
911
925
  : "<p class=\"preview-disclaimer\">No decision recorded yet — pick an option above to preview the saved record.</p>"}
912
926
  ${reviewDecisionPayload
913
927
  ? `<details class="reference-details">
@@ -949,57 +963,83 @@ function renderFooterTally(session) {
949
963
  </footer>
950
964
  `;
951
965
  }
952
- function renderSurfacePreviewSections(preview) {
966
+ function renderSurfacePreviewSections(preview, trace) {
967
+ // Every row below describes the SELECTED candidate. When the reviewer kept the
968
+ // current value, that is a different record from the one the ID stack above
969
+ // describes, so none of these are repeat placements and all of them render —
970
+ // which is the point of keying on fact identity rather than on strings.
971
+ const selected = preview.canonicalClaim.candidateId;
953
972
  return [
954
973
  renderCandidateHistory(preview),
955
- renderSourceEvidence(preview),
974
+ renderSourceEvidence(preview, trace, selected),
956
975
  renderReviewEvent(preview),
957
- renderIntegrityPosture(preview),
976
+ renderIntegrityPosture(preview, trace, selected),
958
977
  renderAuthorityTrace(preview),
959
978
  ].join("");
960
979
  }
961
980
  function renderReviewEvent(preview) {
981
+ // A could-not-confirm resolution projects no review event; five rows reading
982
+ // "unknown / not recorded / pending / not provided" report an absence the
983
+ // decided chip on the card face already states (kontourai/survey#207).
984
+ if (!preview.reviewEvent) {
985
+ return "";
986
+ }
962
987
  return `
963
988
  <section class="preview-section" data-testid="surface-review-event">
964
989
  <h3>${escapeHtml("Review event")}</h3>
965
990
  <dl class="field-stack compact">
966
- ${fieldItem("Actor", preview.reviewEvent?.actor ?? "unknown")}
967
- ${fieldItem("Reviewed at", preview.reviewEvent?.reviewedAt ?? "not recorded")}
968
- ${fieldItem("Status", preview.reviewEvent?.status ?? "pending")}
969
- ${fieldItem("Rationale", preview.reviewEvent?.rationale ?? "No reviewer rationale provided.", "rationale-clamp")}
970
- ${fieldItem("Outcome", preview.reviewEvent?.reviewOutcomeId ?? "not provided")}
991
+ ${fieldItem("actor", "Actor", preview.reviewEvent.actor)}
992
+ ${fieldItem("reviewed-at", "Reviewed at", preview.reviewEvent.reviewedAt)}
993
+ ${fieldItem("status", "Status", preview.reviewEvent.status)}
994
+ ${fieldItem("rationale", "Rationale", preview.reviewEvent.rationale, "rationale-clamp")}
995
+ ${fieldItem("outcome", "Outcome", preview.reviewEvent.reviewOutcomeId)}
971
996
  </dl>
972
997
  </section>
973
998
  `;
974
999
  }
975
- function renderIntegrityPosture(preview) {
1000
+ function renderIntegrityPosture(preview, trace, selected) {
976
1001
  return renderPreviewSection("Integrity posture", "surface-integrity-posture", [
977
- ["Checksum", preview.integrityPosture.checksum],
978
- ], undefined, [
979
- ["Candidate set ID", preview.integrityPosture.candidateSetId],
980
- ["Raw source ID", preview.integrityPosture.rawSourceId],
981
- ["Extraction ID", preview.integrityPosture.extractionId],
982
- ]);
1002
+ { key: "checksum", label: "Checksum", value: preview.integrityPosture.checksum },
1003
+ ], [
1004
+ { key: "candidate-set-id", label: "Candidate set ID", value: preview.integrityPosture.candidateSetId },
1005
+ { key: "raw-source-id", label: "Raw source ID", value: preview.integrityPosture.rawSourceId, fact: { of: selected, property: "source.sourceId" } },
1006
+ { key: "extraction-id", label: "Extraction ID", value: preview.integrityPosture.extractionId, fact: { of: selected, property: "extraction.extractionId" } },
1007
+ ], trace);
983
1008
  }
984
1009
  function renderAuthorityTrace(preview) {
1010
+ // An absent portable authority trace is the common case, and the two rows
1011
+ // that said so were the same sentence on every card of every queue. The
1012
+ // posture statement it repeated lives in the footer disclaimer; the data
1013
+ // stays on SurfaceProjectionPreview for hosts that project it.
1014
+ if (preview.authorityTrace.status === "empty") {
1015
+ return "";
1016
+ }
985
1017
  return renderPreviewSection("Authority trace", "surface-authority-trace", [
986
- ["Status", preview.authorityTrace.label],
987
- ["Detail", preview.authorityTrace.detail],
988
- ], preview.authorityTrace.status === "empty" ? " is-neutral" : "");
1018
+ { key: "authority-trace-status", label: "Status", value: preview.authorityTrace.label },
1019
+ { key: "authority-trace-detail", label: "Detail", value: preview.authorityTrace.detail },
1020
+ ]);
989
1021
  }
990
1022
  function renderCandidateHistory(preview) {
1023
+ // Nothing unselected is not history; the row that said so carried no fact.
991
1024
  if (preview.candidateHistory.length === 0) {
992
- return renderPreviewSection("Unselected candidate history", "surface-candidate-history", [["History", "No unselected candidates."]], undefined, []);
1025
+ return "";
993
1026
  }
994
1027
  const VISIBLE_COUNT = 3;
995
1028
  const total = preview.candidateHistory.length;
996
1029
  const visibleCandidates = preview.candidateHistory.slice(0, VISIBLE_COUNT);
997
1030
  const overflowCandidates = preview.candidateHistory.slice(VISIBLE_COUNT);
998
- const renderHistoryRows = (candidates) => candidates.flatMap((candidate) => [
999
- fieldItem("History", candidate.historyLabel),
1000
- fieldItem("Value", candidate.value),
1001
- ]).join("");
1002
- const referenceRows = preview.candidateHistory.map((candidate) => fieldItem("Candidate ID", candidate.candidateId)).join("");
1031
+ // `historyLabel` is the section's own heading, so a "History: Unselected
1032
+ // candidate history" row above every value restated the <h3> once per
1033
+ // candidate. The label stays on PreviewCandidateHistory for hosts that
1034
+ // project it without a heading of their own.
1035
+ const renderHistoryRows = (candidates) => candidates.map((candidate) => fieldItem("history-value", "Value", candidate.value)).join("");
1036
+ // Deliberately NOT deduplicated. With two candidates this repeats the ID
1037
+ // stack's "Current candidate ID", but it is the only row that names WHICH
1038
+ // unselected candidate a value belongs to, it stops being a repeat the moment
1039
+ // there are three, and suppressing it would make this section's disclosure
1040
+ // appear and disappear with the candidate count — structure a host would then
1041
+ // have to key on. Row-level suppression is what `data-audit-row` is for.
1042
+ const referenceRows = preview.candidateHistory.map((candidate) => fieldItem("candidate-id", "Candidate ID", candidate.candidateId)).join("");
1003
1043
  const overflowHtml = overflowCandidates.length > 0
1004
1044
  ? `<div class="history-overflow">${renderHistoryRows(overflowCandidates)}</div>`
1005
1045
  : "";
@@ -1019,53 +1059,75 @@ function renderCandidateHistory(preview) {
1019
1059
  </dl>
1020
1060
  ${expanderHtml}
1021
1061
  </div>
1022
- ${preview.candidateHistory.length > 0 ? `<details class="reference-details">
1062
+ <details class="reference-details">
1023
1063
  <summary>IDs and trace links</summary>
1024
1064
  <dl class="field-stack compact">${referenceRows}</dl>
1025
- </details>` : ""}
1065
+ </details>
1026
1066
  </section>
1027
1067
  `;
1028
1068
  }
1029
- function renderSourceEvidence(preview) {
1030
- const clampedExcerptHtml = fieldItemClamped("Excerpt", preview.sourceEvidence.excerpt, "excerpt");
1069
+ function renderSourceEvidence(preview, trace, selected) {
1070
+ // The excerpt is quoted on the card face for the PROPOSED candidate. A
1071
+ // decision that selected some other candidate has a different excerpt, and it
1072
+ // renders here.
1073
+ const clampedExcerptHtml = trace.isRepeatPlacement({ of: selected, property: "locator.excerpt" }, preview.sourceEvidence.excerpt)
1074
+ ? ""
1075
+ : fieldItemClamped("excerpt", "Excerpt", preview.sourceEvidence.excerpt, "excerpt");
1031
1076
  return `
1032
1077
  <section class="preview-section" data-testid="surface-source-evidence">
1033
1078
  <h3>${escapeHtml("Raw Source")}</h3>
1034
1079
  <dl class="field-stack compact">
1035
- ${fieldItem("Source Reference", preview.sourceEvidence.sourceRef)}
1080
+ ${fieldItem("source-reference", "Source Reference", preview.sourceEvidence.sourceRef)}
1036
1081
  ${clampedExcerptHtml}
1037
- ${fieldItem("Extractor", preview.sourceEvidence.extractor)}
1038
- ${fieldItem("Observed", preview.sourceEvidence.observedAt)}
1082
+ ${placementItem(trace, { of: selected, property: "extraction.extractor" }, "extractor", "Extractor", preview.sourceEvidence.extractor)}
1083
+ ${fieldItem("observed", "Observed", preview.sourceEvidence.observedAt)}
1039
1084
  ${preview.sourceEvidence.sourceAuthority ? [
1040
- fieldItem("Source authority class", preview.sourceEvidence.sourceAuthority.authorityClass),
1041
- fieldItem("Declared by", preview.sourceEvidence.sourceAuthority.declaredBy),
1042
- fieldItem("Authority scope", preview.sourceEvidence.sourceAuthority.scope),
1085
+ fieldItem("source-authority-class", "Source authority class", preview.sourceEvidence.sourceAuthority.authorityClass),
1086
+ fieldItem("declared-by", "Declared by", preview.sourceEvidence.sourceAuthority.declaredBy),
1087
+ fieldItem("authority-scope", "Authority scope", preview.sourceEvidence.sourceAuthority.scope),
1043
1088
  ].join("") : ""}
1044
1089
  </dl>
1045
1090
  ${renderReferenceDetails([
1046
- ["Raw Source ID", preview.sourceEvidence.sourceId],
1047
- ["Extraction ID", preview.sourceEvidence.extractionId],
1048
- ])}
1091
+ { key: "raw-source-id", label: "Raw Source ID", value: preview.sourceEvidence.sourceId, fact: { of: selected, property: "source.sourceId" } },
1092
+ { key: "extraction-id", label: "Extraction ID", value: preview.sourceEvidence.extractionId, fact: { of: selected, property: "extraction.extractionId" } },
1093
+ ], trace)}
1049
1094
  </section>
1050
1095
  `;
1051
1096
  }
1052
- function renderPreviewSection(title, testId, rows, extraClass = "", references = []) {
1097
+ function renderPreviewRows(rows, trace) {
1098
+ return rows
1099
+ .map((row) => (row.fact && trace
1100
+ ? placementItem(trace, row.fact, row.key, row.label, row.value)
1101
+ : fieldItem(row.key, row.label, row.value)))
1102
+ .join("");
1103
+ }
1104
+ function renderPreviewSection(title, testId, rows, references = [], trace) {
1053
1105
  return `
1054
- <section class="preview-section${extraClass}" data-testid="${testId}">
1106
+ <section class="preview-section" data-testid="${testId}">
1055
1107
  <h3>${escapeHtml(title)}</h3>
1056
1108
  <dl class="field-stack compact">
1057
- ${rows.map(([label, value]) => fieldItem(label, value)).join("")}
1109
+ ${renderPreviewRows(rows, trace)}
1058
1110
  </dl>
1059
- ${references.length > 0 ? renderReferenceDetails(references) : ""}
1111
+ ${references.length > 0 ? renderReferenceDetails(references, trace) : ""}
1060
1112
  </section>
1061
1113
  `;
1062
1114
  }
1063
- function renderReferenceDetails(references) {
1115
+ /**
1116
+ * The per-section "IDs and trace links" list.
1117
+ *
1118
+ * Every call site includes at least one reference that is a first placement and
1119
+ * so can never be suppressed — Raw Source keeps its extraction id, Integrity
1120
+ * posture its candidate set id, and the history section's candidate ids are
1121
+ * exempt outright. The disclosure therefore does not appear and disappear with
1122
+ * the data, which is what a host selecting on structure would otherwise be
1123
+ * exposed to. Keep that property when adding a reference list.
1124
+ */
1125
+ function renderReferenceDetails(references, trace) {
1064
1126
  return `
1065
1127
  <details class="reference-details">
1066
1128
  <summary>IDs and trace links</summary>
1067
1129
  <dl class="field-stack compact">
1068
- ${references.map(([label, value]) => fieldItem(label, value)).join("")}
1130
+ ${renderPreviewRows(references, trace)}
1069
1131
  </dl>
1070
1132
  </details>
1071
1133
  `;
@@ -1253,6 +1315,24 @@ function queueSessionFromStartState(startState) {
1253
1315
  actorId: startState.actorId,
1254
1316
  };
1255
1317
  }
1318
+ const COULD_NOT_CONFIRM_REASON_REQUIRED = "A reason is required when you could not confirm.";
1319
+ /**
1320
+ * Opens every collapsed region between `control` and the card before anything
1321
+ * tries to focus it or anchor a validation bubble on it.
1322
+ *
1323
+ * The browser will not show a validation message on, or move focus into, a
1324
+ * control inside a closed `<details>` — the call silently does nothing. Any
1325
+ * decision path that blocks on an input the reviewer cannot currently see must
1326
+ * go through here, or the control it guards reads as dead (kontourai/survey#208,
1327
+ * after #201 and #203 in the same family).
1328
+ */
1329
+ function revealControl(control) {
1330
+ let ancestor = control?.parentElement?.closest?.("details") ?? null;
1331
+ while (ancestor) {
1332
+ ancestor.open = true;
1333
+ ancestor = ancestor.parentElement?.closest?.("details") ?? null;
1334
+ }
1335
+ }
1256
1336
  function bindFieldCardInteractions(root, controller) {
1257
1337
  root.querySelectorAll("[data-testid='use-proposed']").forEach((button) => {
1258
1338
  button.addEventListener("click", () => {
@@ -1312,12 +1392,25 @@ function bindFieldCardInteractions(root, controller) {
1312
1392
  const note = controller.currentSession().notesByItemName[itemName]?.trim() ?? "";
1313
1393
  const field = button.closest("[data-testid='review-field']");
1314
1394
  const textarea = field?.querySelector("[data-testid='reviewer-note']");
1395
+ const errorEl = field?.querySelector("[data-testid='decision-error']");
1315
1396
  if (!note) {
1316
- textarea?.setCustomValidity?.("A reason is required when you could not confirm.");
1317
- textarea?.reportValidity?.();
1397
+ // Say it where the button is, then open the way to the input that
1398
+ // satisfies it. Focusing (and validating) a control inside a collapsed
1399
+ // <details> is a no-op the reviewer never sees (kontourai/survey#208).
1400
+ if (errorEl) {
1401
+ errorEl.textContent = COULD_NOT_CONFIRM_REASON_REQUIRED;
1402
+ errorEl.hidden = false;
1403
+ }
1404
+ textarea?.setCustomValidity?.(COULD_NOT_CONFIRM_REASON_REQUIRED);
1405
+ revealControl(textarea);
1318
1406
  textarea?.focus();
1407
+ textarea?.reportValidity?.();
1319
1408
  return;
1320
1409
  }
1410
+ if (errorEl) {
1411
+ errorEl.textContent = "";
1412
+ errorEl.hidden = true;
1413
+ }
1321
1414
  textarea?.setCustomValidity?.("");
1322
1415
  controller.setDecision(itemName, "could-not-confirm");
1323
1416
  controller.renderCurrentState();
@@ -1332,6 +1425,13 @@ function bindFieldCardInteractions(root, controller) {
1332
1425
  root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
1333
1426
  textarea.addEventListener("input", () => {
1334
1427
  textarea.setCustomValidity?.("");
1428
+ const decisionError = textarea
1429
+ .closest("[data-testid='review-field']")
1430
+ ?.querySelector("[data-testid='decision-error']");
1431
+ if (decisionError) {
1432
+ decisionError.textContent = "";
1433
+ decisionError.hidden = true;
1434
+ }
1335
1435
  const itemName = textarea.dataset.itemName ?? "";
1336
1436
  controller.updateReviewerNote(itemName, textarea.value);
1337
1437
  refreshAuditPayloadForItem(textarea, controller, itemName);
@@ -1403,9 +1503,9 @@ function bindHistoryExpanders(root) {
1403
1503
  });
1404
1504
  });
1405
1505
  }
1406
- function fieldItemClamped(label, value, extraClass = "") {
1506
+ function fieldItemClamped(key, label, value, extraClass = "") {
1407
1507
  return `
1408
- <div class="kv ${extraClass}">
1508
+ <div class="kv ${extraClass}" data-audit-row="${key}">
1409
1509
  <dt class="field-label">${escapeHtml(label)}</dt>
1410
1510
  <div class="excerpt-clamp" data-clamp>
1411
1511
  <dd class="field-value">${escapeHtml(value)}</dd>
@@ -1414,14 +1514,26 @@ function fieldItemClamped(label, value, extraClass = "") {
1414
1514
  </div>
1415
1515
  `;
1416
1516
  }
1417
- function fieldItem(label, value, extraClass = "") {
1517
+ function fieldItem(key, label, value, extraClass = "") {
1418
1518
  return `
1419
- <div class="kv ${extraClass}">
1519
+ <div class="kv ${extraClass}" data-audit-row="${key}">
1420
1520
  <dt class="field-label">${escapeHtml(label)}</dt>
1421
1521
  <dd class="field-value">${escapeHtml(value)}</dd>
1422
1522
  </div>
1423
1523
  `;
1424
1524
  }
1525
+ /**
1526
+ * A row for a fact the card renders in more than one place. Rendered only if
1527
+ * this card has not already printed the SAME property of the SAME record with
1528
+ * the same value; see {@link AuditFactTrace}.
1529
+ *
1530
+ * Only the four genuinely repeated placements go through here. Every other row
1531
+ * has exactly one home and is rendered with {@link fieldItem} directly, so no
1532
+ * row can ever be suppressed by a fact it has nothing to do with.
1533
+ */
1534
+ function placementItem(trace, fact, key, label, value, extraClass = "") {
1535
+ return trace.isRepeatPlacement(fact, value) ? "" : fieldItem(key, label, value, extraClass);
1536
+ }
1425
1537
  function escapeHtml(value) {
1426
1538
  return formatValue(value)
1427
1539
  .replaceAll("&", "&amp;")
@@ -48,11 +48,37 @@
48
48
  .inspector-pager button:disabled, .queue-pager button:disabled { opacity: .45; }
49
49
  .inspector-candidates { margin: 0; padding-left: 1.5rem; }
50
50
  .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); }
51
- .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; }
51
+ .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; }
52
52
  .inspector-source mark { background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
53
53
  .inspector-candidate:focus { outline: 3px solid var(--k-active); outline-offset: 2px; }
54
- .highlight-anchor { display: inline-block; width: 1px; height: 1em; }
55
- .highlight-anchor:focus { outline: 3px solid var(--k-active); }
54
+ /* An inert link target, one per candidate in the model, so a host's
55
+ `href="#<highlightElementId>"` always resolves. It is not a control and must
56
+ not behave like one: no size, no tab stop, nothing painted. */
57
+ .highlight-anchor {
58
+ display: inline;
59
+ width: 0;
60
+ height: 0;
61
+ overflow: hidden;
62
+ }
63
+
64
+ /* The highlight IS the return control — the only part of this surface a reader
65
+ can see, so it is the thing to aim at. Full phrase width, already visibly
66
+ marked, one tab stop per painted highlight. */
67
+ .source-highlight {
68
+ cursor: pointer;
69
+ border-radius: 2px;
70
+ /* Vertical padding on an inline box grows the hit area without moving the
71
+ line box, and the prepared text's line-height below leaves room for it, so
72
+ the target reaches ~24px tall without lines overlapping each other. The
73
+ phrase itself supplies the width. */
74
+ padding: 5px 3px;
75
+ margin: 0 -3px;
76
+ }
77
+
78
+ .source-highlight:focus-visible {
79
+ outline: 3px solid var(--k-active);
80
+ outline-offset: 1px;
81
+ }
56
82
  .source-unavailable { color: var(--k-negative); font-weight: 700; }
57
83
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
58
84
 
@@ -485,6 +511,22 @@ h1, h2, h3, p {
485
511
  display: none;
486
512
  }
487
513
 
514
+ /* Why a decision was refused, next to the button that refused it. The input
515
+ that satisfies the precondition can be inside the collapsed audit accordion,
516
+ so the message cannot live only with the input (kontourai/survey#208). */
517
+ .derr {
518
+ display: block;
519
+ margin-top: 6px;
520
+ text-align: right;
521
+ font-size: 12px;
522
+ font-weight: 600;
523
+ color: var(--k-negative);
524
+ }
525
+
526
+ .derr[hidden] {
527
+ display: none;
528
+ }
529
+
488
530
  /* confidence + provenance */
489
531
 
490
532
  .prov {
@@ -926,14 +968,6 @@ h1, h2, h3, p {
926
968
  text-transform: uppercase;
927
969
  }
928
970
 
929
- .preview-section.is-neutral {
930
- background: var(--k-raised);
931
- }
932
-
933
- .preview-section.is-neutral h3 {
934
- color: var(--k-faint);
935
- }
936
-
937
971
  .reference-details {
938
972
  margin-top: 8px;
939
973
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "2.2.3",
3
+ "version": "2.3.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",