@kontourai/survey 7.0.0 → 8.1.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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -1
  3. package/dist/src/calibration.d.ts +15 -0
  4. package/dist/src/calibration.js +11 -0
  5. package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
  6. package/dist/src/canonical-reviewed-trust-input.js +15 -0
  7. package/dist/src/console/review-console-server.d.ts +20 -0
  8. package/dist/src/console/review-console-server.js +75 -4
  9. package/dist/src/decision-carry-forward.d.ts +157 -0
  10. package/dist/src/decision-carry-forward.js +295 -0
  11. package/dist/src/extraction-envelope.d.ts +4 -3
  12. package/dist/src/extraction-envelope.js +3 -3
  13. package/dist/src/field-states.d.ts +100 -0
  14. package/dist/src/field-states.js +169 -0
  15. package/dist/src/index.d.ts +3 -1
  16. package/dist/src/index.js +2 -0
  17. package/dist/src/mcp/review-mcp.js +44 -13
  18. package/dist/src/review-resource.d.ts +29 -0
  19. package/dist/src/review-session-file.d.ts +6 -0
  20. package/dist/src/review-session-file.js +2 -0
  21. package/dist/src/review-workbench/audit-sample.d.ts +32 -0
  22. package/dist/src/review-workbench/audit-sample.js +53 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +1 -1
  25. package/dist/src/review-workbench/field-state-view.d.ts +15 -0
  26. package/dist/src/review-workbench/field-state-view.js +84 -0
  27. package/dist/src/review-workbench/review-queue-session.d.ts +46 -1
  28. package/dist/src/review-workbench/review-queue-session.js +74 -3
  29. package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
  30. package/dist/src/review-workbench/review-session-replay.js +15 -1
  31. package/dist/src/review-workbench/review-workbench-css.generated.d.ts +9 -0
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +453 -43
  33. package/dist/src/review-workbench/review-workbench-element.js +68 -212
  34. package/dist/src/review-workbench/review-workbench.css +386 -42
  35. package/dist/src/review-workbench/review-workbench.d.ts +13 -1
  36. package/dist/src/review-workbench/review-workbench.js +41 -15
  37. package/dist/src/review-workbench/review-workbench.standalone.css +37 -23
  38. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/themes.css +189 -10
  39. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/tokens.css +156 -5
  40. package/package.json +13 -8
@@ -1,6 +1,6 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { candidateForDecision, conflictSelectionIssue, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, unchosenProposedCandidates, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
+ import { candidateForDecision, conflictSelectionIssue, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, unchosenProposedCandidates, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, isScoreBlind, assertReviewSessionConditions, sessionConditionsStamp, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
4
4
  import { reviewSessionReplayWarningsForSnapshot, 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";
@@ -9,11 +9,14 @@ import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review
9
9
  import { attestReviewQueueExtraction } from "./queue-binding.js";
10
10
  import { candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "./review-presentation.js";
11
11
  import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
12
+ import { renderFieldStatesHtml } from "./field-state-view.js";
12
13
  import { createAuditFactTrace, } from "./audit-rows.js";
13
14
  export { reviewAuditRowKeys } from "./audit-rows.js";
14
15
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
15
16
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
16
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, conflictSelectionIssue, unchosenProposedCandidates, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
17
+ export { renderFieldStatesHtml } from "./field-state-view.js";
18
+ export { drawRandomAuditSample, openRandomAuditSession, } from "./audit-sample.js";
19
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, conflictSelectionIssue, unchosenProposedCandidates, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, isScoreBlind, assertReviewSessionConditions, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
17
20
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
18
21
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
19
22
  export { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
@@ -24,6 +27,7 @@ export function buildReviewDecision(state, options = {}) {
24
27
  return undefined;
25
28
  }
26
29
  const definition = workbenchDecisionDefinitions[state.decision];
30
+ assertReviewSessionConditions(state);
27
31
  if (state.decision === "could-not-confirm" && !state.note.trim()) {
28
32
  throw new Error("Could not confirm requires a non-empty reason.");
29
33
  }
@@ -70,6 +74,10 @@ export function buildReviewDecision(state, options = {}) {
70
74
  : {}),
71
75
  // A choice between conflicting values records the rivals it passed over.
72
76
  ...(unchosen.length ? { unselectedCandidateIds: unchosen.map((entry) => entry.id) } : {}),
77
+ // The session's conditions, never the reviewer's: a score-blind session
78
+ // records that the decision was made without scores on screen.
79
+ ...(state.presentation !== undefined ? { presentation: { scoreBlind: state.presentation.scoreBlind } } : {}),
80
+ ...(state.sampling !== undefined ? { sampling: { ...state.sampling } } : {}),
73
81
  },
74
82
  status: {
75
83
  ...(candidateProjection?.claimId ? { appliedToClaimIds: [candidateProjection.claimId] } : {}),
@@ -140,7 +148,8 @@ function decisionCardRenderedPrompt(state, presentation, adapter) {
140
148
  const excluded = excludedProposalsSentence(presentation.excludedProposals);
141
149
  const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
142
150
  // Likewise the verifier records the card shows, read against the same value.
143
- const verification = candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
151
+ // A score-blind card shows no verifier result, so the recorded prompt names none.
152
+ const verification = isScoreBlind(state) ? [] : candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
144
153
  return [decisionCardBasePrompt(state, presentation.targetLabel, adapter), ...(excluded ? [excluded] : []), ...(unreadable ? [unreadable] : []), ...verification].join(" ");
145
154
  }
146
155
  function decisionCardBasePrompt(state, targetLabel, adapter) {
@@ -514,7 +523,7 @@ export function createPersistentReviewSessionEventStore(options) {
514
523
  }
515
524
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
516
525
  const session = queueSessionFromStartState(state);
517
- return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport));
526
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport), options.fieldStates);
518
527
  }
519
528
  /**
520
529
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -602,7 +611,7 @@ function isEmptyValue(value) {
602
611
  }
603
612
  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>";
604
613
  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>";
605
- function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation) {
614
+ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation, fieldStates) {
606
615
  // A queue that does not match its stored extraction import is not reviewed:
607
616
  // its items could hide or alter what the source said.
608
617
  if (attestation.state === "diverges") {
@@ -625,6 +634,9 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
625
634
  ${attestation.state === "unverified"
626
635
  ? `<div class="queue-attestation unverified" data-testid="queue-attestation" data-state="unverified" role="note">${WARNING_SVG}<span>${escapeHtml(attestation.message)}</span></div>`
627
636
  : ""}
637
+ ${isScoreBlind(session)
638
+ ? `<div class="queue-attestation score-blind" data-testid="score-blind-notice" data-sampling="${escapeHtml(session.sampling?.kind ?? "")}" role="note"><span>${escapeHtml(scoreBlindNotice(session))}</span></div>`
639
+ : ""}
628
640
  <header class="rhead">
629
641
  <div class="top">
630
642
  <div class="subj">
@@ -641,6 +653,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, a
641
653
  <button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
642
654
  </div>
643
655
  </header>
656
+ ${fieldStates ? renderFieldStatesHtml(fieldStates, session) : ""}
644
657
  <nav class="queue-controls" aria-label="Review queue navigation">
645
658
  <label>
646
659
  <span>Find fields</span>
@@ -730,9 +743,9 @@ function renderFieldCard(item, session, presentationAdapter) {
730
743
  : proposedCandidates.length > 1
731
744
  ? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
732
745
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
733
- ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
746
+ ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter, isScoreBlind(session)) : ""}
734
747
  ${renderExtractionImportNotes(presentation)}
735
- ${renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
748
+ ${isScoreBlind(session) ? "" : renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
736
749
  <div class="decide">
737
750
  ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
738
751
  ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
@@ -955,7 +968,7 @@ export function validateProposedValue(descriptor, rawValue) {
955
968
  return undefined;
956
969
  }
957
970
  }
958
- function renderProvenanceRow(item, proposed, presentationAdapter) {
971
+ function renderProvenanceRow(item, proposed, presentationAdapter, scoreBlind) {
959
972
  const excerpt = proposed.locator?.excerpt;
960
973
  if (!excerpt) {
961
974
  return `
@@ -968,7 +981,8 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
968
981
  </div>
969
982
  `;
970
983
  }
971
- const confidence = proposed.extraction.confidence ?? proposed.confidence;
984
+ // A score-blind session never renders the proposer's confidence.
985
+ const confidence = scoreBlind ? undefined : proposed.extraction.confidence ?? proposed.confidence;
972
986
  const presentation = buildReviewCandidatePresentation(item, proposed, presentationAdapter);
973
987
  const sourceLinkHtml = presentation.sourceLink
974
988
  ? `<a href="${escapeHtml(presentation.sourceLink.href)}">${escapeHtml(presentation.sourceLink.label ?? presentation.sourceText)}</a>`
@@ -1064,6 +1078,8 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
1064
1078
  selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
1065
1079
  reviewedAt: session.reviewedAt,
1066
1080
  actorId: session.actorId,
1081
+ ...(session.presentation !== undefined ? { presentation: session.presentation } : {}),
1082
+ ...(session.sampling !== undefined ? { sampling: session.sampling } : {}),
1067
1083
  };
1068
1084
  const reviewDecisionPayload = buildReviewDecision(state, { presentationAdapter });
1069
1085
  const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
@@ -1348,10 +1364,14 @@ function createReviewWorkbenchController(root, startState, options) {
1348
1364
  const attemptEvidenceIds = itemName && decision === "could-not-confirm"
1349
1365
  ? session.attemptEvidenceIdsByItemName?.[itemName]
1350
1366
  : undefined;
1367
+ const conditions = sessionConditionsStamp(session);
1351
1368
  const defaultData = decision
1352
- ? (editedValue !== undefined
1353
- ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
1354
- : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) })
1369
+ ? {
1370
+ ...(editedValue !== undefined
1371
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
1372
+ : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) }),
1373
+ ...(conditions ? { sessionConditions: conditions } : {}),
1374
+ }
1355
1375
  : undefined;
1356
1376
  return buildReviewSessionEvent(session, {
1357
1377
  sessionName: defaultReviewSessionName,
@@ -1439,7 +1459,7 @@ function createReviewWorkbenchController(root, startState, options) {
1439
1459
  currentSession: () => session,
1440
1460
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
1441
1461
  presentationAdapter: options.presentationAdapter,
1442
- renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation),
1462
+ renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation, options.fieldStates),
1443
1463
  setDecision,
1444
1464
  clearDecision,
1445
1465
  updateReviewerNote,
@@ -1459,8 +1479,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
1459
1479
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1460
1480
  return `${keyPrefix}:${itemNames}`;
1461
1481
  }
1462
- function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation) {
1463
- root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined));
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);
1464
1484
  bindFieldCardInteractions(root, controller);
1465
1485
  bindApplyButton(root, controller);
1466
1486
  bindClampToggles(root);
@@ -1513,8 +1533,14 @@ function queueSessionFromStartState(startState) {
1513
1533
  : {}),
1514
1534
  reviewedAt: startState.reviewedAt,
1515
1535
  actorId: startState.actorId,
1536
+ ...(startState.presentation !== undefined ? { presentation: startState.presentation } : {}),
1537
+ ...(startState.sampling !== undefined ? { sampling: startState.sampling } : {}),
1516
1538
  };
1517
1539
  }
1540
+ function scoreBlindNotice(session) {
1541
+ const audit = session.sampling?.kind === "random-audit" ? "Random audit sample. " : "";
1542
+ return `${audit}Score-blind review: confidence and verifier results are hidden for this whole session. Decide from the source and its excerpt.`;
1543
+ }
1518
1544
  const COULD_NOT_CONFIRM_REASON_REQUIRED = "A reason is required when you could not confirm.";
1519
1545
  /**
1520
1546
  * Opens every collapsed region between `control` and the card before anything
@@ -91,31 +91,13 @@
91
91
  .queue-result-count { margin-left: auto; color: var(--k-muted); }
92
92
  .queue-empty { padding: 2rem; color: var(--k-muted); text-align: center; }
93
93
 
94
- /* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
95
- preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
96
- works" without requiring [data-theme] to be set), and a bare class selector
97
- ties with the `[data-theme="light"]` selector in tokens.css on specificity —
98
- so source order decides, and themes.css loads after tokens.css. Re-declaring
99
- the light values here (loaded after themes.css) wins that tie-break. Presets
100
- still control --k-brand (and theme-console's fuller palette) via their own,
101
- more specific `[data-theme="light"].theme-*` rules in themes.css. */
94
+ /* Light-mode token values come from @kontourai/ui (tokens.css plus each
95
+ theme's light block, which since 1.17 re-sets every mode-dependent value
96
+ its dark preset sets). Only color-scheme is re-asserted here: the :root rule
97
+ above sets it to dark and would otherwise win the source-order tie with
98
+ tokens.css's [data-theme="light"], leaving native controls dark. */
102
99
  [data-theme="light"] {
103
100
  color-scheme: light;
104
- --k-bg: #f5f4ef;
105
- --k-panel: #ffffff;
106
- --k-panel-raised: #fbfaf7;
107
- --k-line: rgba(36, 40, 46, 0.12);
108
- --k-line-strong: rgba(36, 40, 46, 0.20);
109
- --k-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.25);
110
- --k-text: #202124;
111
- --k-text-muted: #5b626b;
112
- --k-text-faint: #707782;
113
- --k-brand-contrast: #ffffff;
114
- --k-positive: #168257;
115
- --k-caution: #8a5a00;
116
- --k-negative: #c83b3b;
117
- --k-neutral: #5f6975;
118
- --k-active: #3f6fd6;
119
101
  }
120
102
 
121
103
  * {
@@ -409,6 +391,30 @@ h1, h2, h3, p {
409
391
  .chip.rejected { color: var(--k-negative); background: var(--k-negative-wash); }
410
392
  .chip.could-not-confirm { color: var(--k-caution); background: var(--k-caution-wash); }
411
393
 
394
+ /* Field-state panel: one row per field, content and lifecycle derived from
395
+ the records, never stored labels. A field that was not read is listed. */
396
+ .field-states { padding: var(--k-space-3, 12px) 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
397
+ .field-states h2 { margin: 0 0 8px; font-size: 13px; font-weight: 700; color: var(--k-text); }
398
+ .field-states ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
399
+ .field-states-lead { margin: 0 0 8px; font-size: 13px; line-height: 1.45; color: var(--k-caution); }
400
+ .field-state-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: center; padding: 6px 0; border-top: 1px solid var(--k-line); }
401
+ .field-state-row:first-child { border-top: 0; }
402
+ .field-state-name { font-family: var(--k-font-mono, ui-monospace, monospace); font-size: 12px; color: var(--k-text); overflow-wrap: anywhere; }
403
+ .field-state-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
404
+ .field-state-note { grid-column: 1 / -1; font-size: 12px; color: var(--k-muted); }
405
+ .field-state-note.signal { color: var(--k-caution); }
406
+ .chip.state-content.value { color: var(--k-positive); background: var(--k-positive-wash); }
407
+ .chip.state-content.conflicting,
408
+ .chip.state-content.excluded,
409
+ .chip.state-content.not_covered { color: var(--k-caution); background: var(--k-caution-wash); }
410
+ .chip.state-content.unsupported { color: var(--k-negative); background: var(--k-negative-wash); }
411
+ .chip.state-content.none,
412
+ .chip.state-lifecycle.superseded { color: var(--k-muted); background: var(--k-sunken); }
413
+ .chip.state-lifecycle.pending { color: var(--k-brand); background: var(--k-brand-wash); }
414
+ .chip.state-lifecycle.accepted { color: var(--k-positive); background: var(--k-positive-wash); }
415
+ .chip.state-lifecycle.rejected { color: var(--k-negative); background: var(--k-negative-wash); }
416
+ .chip.state-lifecycle.could_not_confirm { color: var(--k-caution); background: var(--k-caution-wash); }
417
+
412
418
  .frow1 .push {
413
419
  margin-left: auto;
414
420
  }
@@ -653,6 +659,12 @@ h1, h2, h3, p {
653
659
  background: var(--k-negative-wash);
654
660
  }
655
661
 
662
+ /* A score-blind session: an informational note, not a warning. */
663
+ .queue-attestation.score-blind {
664
+ color: var(--k-active);
665
+ background: var(--k-active-soft);
666
+ }
667
+
656
668
  /* Conflicting proposed values: each value is listed with its own choice
657
669
  control until the field is decided, then marked chosen or not chosen. */
658
670
  .conflict-values ul {
@@ -1213,6 +1225,8 @@ pre[data-testid="decision-payload"] {
1213
1225
  /* The diff-collapse must also hold in host contexts without a query container
1214
1226
  (e.g. an embed that doesn't establish `container-type`): fall back to viewport. */
1215
1227
  @media (max-width: 620px) {
1228
+ .field-state-row { grid-template-columns: minmax(0, 1fr); }
1229
+ .field-state-chips { justify-content: flex-start; }
1216
1230
  .diff {
1217
1231
  grid-template-columns: 1fr;
1218
1232
  }
@@ -1,7 +1,39 @@
1
+ /* Product themes. Each theme sets its brand (identity) and, beside it, the
2
+ action and focus roles. The roles hold their own values rather than aliasing
3
+ the brand, so a later brand override cannot break primary actions or focus.
4
+ Every scope that sets --k-action also sets --k-action-contrast: a theme
5
+ nested under another theme's scope would otherwise inherit that scope's text
6
+ color onto its own fill.
7
+
8
+ Every scope that sets --k-focus also redeclares --k-focus-ring, and every
9
+ scope that sets a status hue redeclares its soft fill: a var()-derived token
10
+ resolves where it is declared, so without this a scope below the root would
11
+ inherit the root's value (issue 78). check:tokens enforces both.
12
+
13
+ Each light block matches three placements of the theme class and
14
+ data-theme="light": on one element, the theme class below the light
15
+ attribute, and the light attribute below the theme class. The last is
16
+ wrapped in :where() so it weighs the same as the bare [data-theme="light"]
17
+ block in tokens.css (which it follows, so it wins there) and less than a
18
+ theme class sitting on the light element itself: a nested theme keeps its
19
+ own identity.
20
+
21
+ Scoping rule (ui#80, ui#84; DESIGN.md "Theme and mode scoping"): the
22
+ nearest data-theme picks the mode and the nearest theme class picks the
23
+ product. The :where(:not(...)) tails enforce "nearest" without changing a
24
+ selector's weight: the class-below-attribute form stops at a nearer
25
+ data-theme="dark", and the attribute-below-class form stops at a nearer
26
+ (or its own) other theme class. Weights are unchanged, so an override
27
+ declared later with these selectors still wins. */
28
+
1
29
  .theme-survey {
2
30
  --k-bg: #06080b;
3
31
  --k-brand: #5ce0c6;
4
- --k-text-faint: #6f8095;
32
+ --k-action: #5ce0c6;
33
+ --k-action-contrast: #06080b;
34
+ --k-focus: #5ce0c6;
35
+ --k-text-faint: #78889b;
36
+ --k-focus-ring: var(--k-focus);
5
37
  }
6
38
 
7
39
  .theme-console {
@@ -13,6 +45,9 @@
13
45
  --k-radius-overlay: 0;
14
46
  --k-brand: #c9ff4a;
15
47
  --k-brand-contrast: #11120f;
48
+ --k-action: #c9ff4a;
49
+ --k-action-contrast: #11120f;
50
+ --k-focus: #c9ff4a;
16
51
  --k-bg: #11120f;
17
52
  --k-panel: #191b16;
18
53
  --k-panel-raised: #20231e;
@@ -20,30 +55,81 @@
20
55
  --k-line-strong: #8ea36e;
21
56
  --k-text: #f2f0e8;
22
57
  --k-text-muted: #a6ab9c;
23
- --k-text-faint: #747a6c;
58
+ --k-text-faint: #858b7e;
24
59
  --k-positive: #a6d37b;
25
60
  --k-caution: #e8c15f;
26
61
  --k-negative: #ee776f;
27
62
  --k-active: #84d8c8;
63
+ --k-status-contrast: #11120f;
64
+ --k-focus-ring: var(--k-focus);
65
+ --k-positive-soft: color-mix(in oklab, var(--k-positive) 14%, transparent);
66
+ --k-caution-soft: color-mix(in oklab, var(--k-caution) 14%, transparent);
67
+ --k-negative-soft: color-mix(in oklab, var(--k-negative) 14%, transparent);
68
+ --k-active-soft: color-mix(in oklab, var(--k-active) 14%, transparent);
28
69
  }
29
70
 
71
+ /* Flow dark: the brand is the accent lightened (OKLCH hue within 0.3°, same chroma)
72
+ just enough to read as text at 4.5:1 on bg, panel and raised panel; the
73
+ action and focus keep the accent, which clears their 3:1 and dark-text
74
+ pairs. */
30
75
  .theme-flow {
31
- --k-brand: #2f88a6;
76
+ --k-brand: #3890ae;
77
+ --k-action: #2f88a6;
78
+ --k-action-contrast: #06080b;
79
+ --k-focus: #2f88a6;
80
+ --k-focus-ring: var(--k-focus);
32
81
  }
33
82
 
34
83
  .theme-surface {
35
84
  --k-brand: #14a37a;
85
+ --k-action: #14a37a;
86
+ --k-action-contrast: #06080b;
87
+ --k-focus: #14a37a;
88
+ --k-focus-ring: var(--k-focus);
36
89
  }
37
90
 
91
+ /* Station: the draft accent, adjusted per mode only where WCAG AA needs it.
92
+ Dark: the accent carries white text as a fill but is too dark to read as
93
+ text on the dark panel, so the focus ring is the accent lightened to clear
94
+ contrast, and the brand is lightened a little further (OKLCH hue within 0.1°) to
95
+ read as text at 4.5:1 on bg, panel and raised panel; the action keeps the
96
+ draft accent. */
97
+ .theme-station {
98
+ --k-brand: #966aff;
99
+ --k-action: #7c3aed;
100
+ --k-action-contrast: #ffffff;
101
+ --k-focus: #9364ff;
102
+ --k-focus-ring: var(--k-focus);
103
+ }
104
+
105
+ /* Survey light: the brand is the accent darkened (same OKLCH hue and chroma) just enough
106
+ to read as text at 4.5:1 on the light canvas; the action and focus keep the
107
+ accent. */
38
108
  [data-theme="light"].theme-survey,
39
- [data-theme="light"] .theme-survey {
40
- --k-brand: #16806f;
109
+ [data-theme="light"] .theme-survey:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
110
+ :where(.theme-survey) [data-theme="light"]:where(:not(.theme-console, .theme-flow, .theme-surface, .theme-station, .theme-survey :is(.theme-console, .theme-flow, .theme-surface, .theme-station) *)) {
111
+ --k-bg: #f5f4ef;
112
+ --k-text-faint: #6a707b;
113
+ --k-brand: #107e6d;
114
+ --k-action: #16806f;
115
+ --k-action-contrast: #ffffff;
116
+ --k-focus: #16806f;
117
+ --k-focus-ring: var(--k-focus);
41
118
  }
42
119
 
120
+ /* Console light: the brand is the lime darkened (OKLCH hue within 0.1°) just enough
121
+ to read as text at 4.5:1 on bg, panel and raised panel, and it keeps white
122
+ brand-contrast text (5.1:1; dark text would be 3.7:1). The action and focus
123
+ keep the brighter lime with dark text. */
43
124
  [data-theme="light"].theme-console,
44
- [data-theme="light"] .theme-console {
45
- --k-brand: #6c9400;
125
+ [data-theme="light"] .theme-console:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
126
+ :where(.theme-console) [data-theme="light"]:where(:not(.theme-survey, .theme-flow, .theme-surface, .theme-station, .theme-console :is(.theme-survey, .theme-flow, .theme-surface, .theme-station) *)) {
127
+ --k-brand: #577800;
46
128
  --k-brand-contrast: #ffffff;
129
+ --k-action: #6c9400;
130
+ --k-action-contrast: #11120f;
131
+ --k-focus: #6c9400;
132
+ --k-focus-ring: var(--k-focus);
47
133
  --k-bg: #f3f5eb;
48
134
  --k-panel: #fbfcf7;
49
135
  --k-panel-raised: #eef2e6;
@@ -51,19 +137,112 @@
51
137
  --k-line-strong: #8fa36f;
52
138
  --k-text: #1e2319;
53
139
  --k-text-muted: #596250;
54
- --k-text-faint: #77816d;
140
+ --k-text-faint: #68705f;
55
141
  --k-positive: #2f7d32;
56
142
  --k-caution: #8a6500;
57
143
  --k-negative: #b93a36;
58
144
  --k-active: #247f75;
145
+ --k-status-contrast: #ffffff;
146
+ --k-positive-soft: color-mix(in oklab, var(--k-positive) 14%, transparent);
147
+ --k-caution-soft: color-mix(in oklab, var(--k-caution) 14%, transparent);
148
+ --k-negative-soft: color-mix(in oklab, var(--k-negative) 14%, transparent);
149
+ --k-active-soft: color-mix(in oklab, var(--k-active) 14%, transparent);
59
150
  }
60
151
 
61
152
  [data-theme="light"].theme-flow,
62
- [data-theme="light"] .theme-flow {
153
+ [data-theme="light"] .theme-flow:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
154
+ :where(.theme-flow) [data-theme="light"]:where(:not(.theme-survey, .theme-console, .theme-surface, .theme-station, .theme-flow :is(.theme-survey, .theme-console, .theme-surface, .theme-station) *)) {
63
155
  --k-brand: #1f6f88;
156
+ --k-action: #1f6f88;
157
+ --k-action-contrast: #ffffff;
158
+ --k-focus: #1f6f88;
159
+ --k-focus-ring: var(--k-focus);
64
160
  }
65
161
 
66
162
  [data-theme="light"].theme-surface,
67
- [data-theme="light"] .theme-surface {
163
+ [data-theme="light"] .theme-surface:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
164
+ :where(.theme-surface) [data-theme="light"]:where(:not(.theme-survey, .theme-console, .theme-flow, .theme-station, .theme-surface :is(.theme-survey, .theme-console, .theme-flow, .theme-station) *)) {
68
165
  --k-brand: #0f6b52;
166
+ --k-action: #0f6b52;
167
+ --k-action-contrast: #ffffff;
168
+ --k-focus: #0f6b52;
169
+ --k-focus-ring: var(--k-focus);
170
+ }
171
+
172
+ [data-theme="light"].theme-station,
173
+ [data-theme="light"] .theme-station:where(:not([data-theme="dark"], [data-theme="light"] [data-theme="dark"] *)),
174
+ :where(.theme-station) [data-theme="light"]:where(:not(.theme-survey, .theme-console, .theme-flow, .theme-surface, .theme-station :is(.theme-survey, .theme-console, .theme-flow, .theme-surface) *)) {
175
+ --k-brand: #7c3aed;
176
+ --k-action: #7c3aed;
177
+ --k-action-contrast: #ffffff;
178
+ --k-focus: #7c3aed;
179
+ --k-focus-ring: var(--k-focus);
180
+ }
181
+
182
+
183
+ /* Dark islands (ui#80): a data-theme="dark" element below a light element
184
+ takes its nearest theme's dark values. tokens.css resets it to the default
185
+ dark contract first; each block below repeats its theme's mode-dependent
186
+ base values (check:contrast fails if one drifts) and, like the light
187
+ blocks, yields to a nearer theme class. Without a light ancestor nothing
188
+ needs resetting, so these blocks do not match and the element inherits. */
189
+
190
+ [data-theme="dark"]:where(.theme-survey *):where([data-theme="light"] *):where(:not(.theme-console, .theme-flow, .theme-surface, .theme-station, .theme-survey :is(.theme-console, .theme-flow, .theme-surface, .theme-station) *)) {
191
+ --k-bg: #06080b;
192
+ --k-brand: #5ce0c6;
193
+ --k-action: #5ce0c6;
194
+ --k-action-contrast: #06080b;
195
+ --k-focus: #5ce0c6;
196
+ --k-text-faint: #78889b;
197
+ --k-focus-ring: var(--k-focus);
198
+ }
199
+
200
+ [data-theme="dark"]:where(.theme-console *):where([data-theme="light"] *):where(:not(.theme-survey, .theme-flow, .theme-surface, .theme-station, .theme-console :is(.theme-survey, .theme-flow, .theme-surface, .theme-station) *)) {
201
+ --k-brand: #c9ff4a;
202
+ --k-brand-contrast: #11120f;
203
+ --k-action: #c9ff4a;
204
+ --k-action-contrast: #11120f;
205
+ --k-focus: #c9ff4a;
206
+ --k-bg: #11120f;
207
+ --k-panel: #191b16;
208
+ --k-panel-raised: #20231e;
209
+ --k-line: #3a4035;
210
+ --k-line-strong: #8ea36e;
211
+ --k-text: #f2f0e8;
212
+ --k-text-muted: #a6ab9c;
213
+ --k-text-faint: #858b7e;
214
+ --k-positive: #a6d37b;
215
+ --k-caution: #e8c15f;
216
+ --k-negative: #ee776f;
217
+ --k-active: #84d8c8;
218
+ --k-status-contrast: #11120f;
219
+ --k-focus-ring: var(--k-focus);
220
+ --k-positive-soft: color-mix(in oklab, var(--k-positive) 14%, transparent);
221
+ --k-caution-soft: color-mix(in oklab, var(--k-caution) 14%, transparent);
222
+ --k-negative-soft: color-mix(in oklab, var(--k-negative) 14%, transparent);
223
+ --k-active-soft: color-mix(in oklab, var(--k-active) 14%, transparent);
224
+ }
225
+
226
+ [data-theme="dark"]:where(.theme-flow *):where([data-theme="light"] *):where(:not(.theme-survey, .theme-console, .theme-surface, .theme-station, .theme-flow :is(.theme-survey, .theme-console, .theme-surface, .theme-station) *)) {
227
+ --k-brand: #3890ae;
228
+ --k-action: #2f88a6;
229
+ --k-action-contrast: #06080b;
230
+ --k-focus: #2f88a6;
231
+ --k-focus-ring: var(--k-focus);
232
+ }
233
+
234
+ [data-theme="dark"]:where(.theme-surface *):where([data-theme="light"] *):where(:not(.theme-survey, .theme-console, .theme-flow, .theme-station, .theme-surface :is(.theme-survey, .theme-console, .theme-flow, .theme-station) *)) {
235
+ --k-brand: #14a37a;
236
+ --k-action: #14a37a;
237
+ --k-action-contrast: #06080b;
238
+ --k-focus: #14a37a;
239
+ --k-focus-ring: var(--k-focus);
240
+ }
241
+
242
+ [data-theme="dark"]:where(.theme-station *):where([data-theme="light"] *):where(:not(.theme-survey, .theme-console, .theme-flow, .theme-surface, .theme-station :is(.theme-survey, .theme-console, .theme-flow, .theme-surface) *)) {
243
+ --k-brand: #966aff;
244
+ --k-action: #7c3aed;
245
+ --k-action-contrast: #ffffff;
246
+ --k-focus: #9364ff;
247
+ --k-focus-ring: var(--k-focus);
69
248
  }