@kontourai/survey 2.2.1 → 2.2.3

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.
@@ -3,149 +3,149 @@
3
3
  color-scheme: dark;
4
4
 
5
5
  /* structure */
6
- --k-bg: var(--k-bg, #0a0e13);
7
- --k-panel: var(--k-panel, #111824);
8
- --k-panel-raised: var(--k-panel-raised, #16202d);
9
- --k-line: var(--k-line, rgba(150, 180, 210, 0.12));
10
- --k-line-strong: var(--k-line-strong, rgba(150, 180, 210, 0.22));
11
- --k-shadow: var(--k-shadow, 0 26px 60px -42px rgba(0, 0, 0, 0.95));
6
+ --k-bg: #0a0e13;
7
+ --k-panel: #111824;
8
+ --k-panel-raised: #16202d;
9
+ --k-line: rgba(150, 180, 210, 0.12);
10
+ --k-line-strong: rgba(150, 180, 210, 0.22);
11
+ --k-shadow: 0 26px 60px -42px rgba(0, 0, 0, 0.95);
12
12
 
13
13
  /* text */
14
- --k-text: var(--k-text, #eef3f8);
15
- --k-text-muted: var(--k-text-muted, #aebccb);
16
- --k-text-faint: var(--k-text-faint, #72869b);
14
+ --k-text: #eef3f8;
15
+ --k-text-muted: #aebccb;
16
+ --k-text-faint: #72869b;
17
17
 
18
18
  /* brand */
19
- --k-brand: var(--k-brand, #5ce0c6);
20
- --k-brand-contrast: var(--k-brand-contrast, #06080b);
19
+ --k-brand: #5ce0c6;
20
+ --k-brand-contrast: #06080b;
21
21
 
22
22
  /* semantic status scale */
23
- --k-positive: var(--k-positive, #34d399);
24
- --k-caution: var(--k-caution, #f3b14b);
25
- --k-negative: var(--k-negative, #ff6f6f);
26
- --k-neutral: var(--k-neutral, #6f8095);
27
- --k-active: var(--k-active, #7aa2ff);
23
+ --k-positive: #34d399;
24
+ --k-caution: #f3b14b;
25
+ --k-negative: #ff6f6f;
26
+ --k-neutral: #6f8095;
27
+ --k-active: #7aa2ff;
28
28
  --k-positive-soft: color-mix(in oklab, var(--k-positive) 14%, transparent);
29
29
  --k-caution-soft: color-mix(in oklab, var(--k-caution) 14%, transparent);
30
30
  --k-negative-soft: color-mix(in oklab, var(--k-negative) 14%, transparent);
31
31
  --k-active-soft: color-mix(in oklab, var(--k-active) 14%, transparent);
32
32
 
33
33
  /* spacing */
34
- --k-space-1: var(--k-space-1, 4px);
35
- --k-space-2: var(--k-space-2, 8px);
36
- --k-space-3: var(--k-space-3, 12px);
37
- --k-space-4: var(--k-space-4, 16px);
38
- --k-space-5: var(--k-space-5, 24px);
39
- --k-space-6: var(--k-space-6, 32px);
34
+ --k-space-1: 4px;
35
+ --k-space-2: 8px;
36
+ --k-space-3: 12px;
37
+ --k-space-4: 16px;
38
+ --k-space-5: 24px;
39
+ --k-space-6: 32px;
40
40
 
41
41
  /* radius */
42
- --k-radius-sm: var(--k-radius-sm, 9px);
43
- --k-radius-md: var(--k-radius-md, 14px);
42
+ --k-radius-sm: 9px;
43
+ --k-radius-md: 14px;
44
44
 
45
45
  /* type */
46
- --k-text-xs: var(--k-text-xs, 11px);
47
- --k-text-sm: var(--k-text-sm, 12.5px);
48
- --k-text-md: var(--k-text-md, 14px);
49
- --k-text-lg: var(--k-text-lg, 18px);
50
- --k-text-xl: var(--k-text-xl, 22px);
51
- --k-text-2xl: var(--k-text-2xl, clamp(26px, 3.4vw, 38px));
46
+ --k-text-xs: 11px;
47
+ --k-text-sm: 12.5px;
48
+ --k-text-md: 14px;
49
+ --k-text-lg: 18px;
50
+ --k-text-xl: 22px;
51
+ --k-text-2xl: clamp(26px, 3.4vw, 38px);
52
52
 
53
53
  /* fonts */
54
- --k-font-display: var(--k-font-display, "Fraunces", Georgia, "Times New Roman", serif);
55
- --k-font-ui: var(--k-font-ui, "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
56
- --k-font-mono: var(--k-font-mono, "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace);
54
+ --k-font-display: "Fraunces", Georgia, "Times New Roman", serif;
55
+ --k-font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
56
+ --k-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
57
57
 
58
58
  /* motion */
59
- --k-ease: var(--k-ease, cubic-bezier(0.22, 1, 0.36, 1));
60
- --k-dur: var(--k-dur, 0.16s);
59
+ --k-ease: cubic-bezier(0.22, 1, 0.36, 1);
60
+ --k-dur: 0.16s;
61
61
  }
62
62
 
63
63
  .survey-workbench-embed[data-theme="light"]{
64
64
  color-scheme: light;
65
- --k-bg: var(--k-bg, #f5f4ef);
66
- --k-panel: var(--k-panel, #ffffff);
67
- --k-panel-raised: var(--k-panel-raised, #fbfaf7);
68
- --k-line: var(--k-line, rgba(36, 40, 46, 0.12));
69
- --k-line-strong: var(--k-line-strong, rgba(36, 40, 46, 0.20));
70
- --k-shadow: var(--k-shadow, 0 18px 50px -30px rgba(0, 0, 0, 0.25));
71
- --k-text: var(--k-text, #202124);
72
- --k-text-muted: var(--k-text-muted, #5b626b);
73
- --k-text-faint: var(--k-text-faint, #707782);
74
- --k-brand-contrast: var(--k-brand-contrast, #ffffff);
75
- --k-positive: var(--k-positive, #168257);
76
- --k-caution: var(--k-caution, #8a5a00);
77
- --k-negative: var(--k-negative, #c83b3b);
78
- --k-neutral: var(--k-neutral, #5f6975);
79
- --k-active: var(--k-active, #3f6fd6);
65
+ --k-bg: #f5f4ef;
66
+ --k-panel: #ffffff;
67
+ --k-panel-raised: #fbfaf7;
68
+ --k-line: rgba(36, 40, 46, 0.12);
69
+ --k-line-strong: rgba(36, 40, 46, 0.20);
70
+ --k-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.25);
71
+ --k-text: #202124;
72
+ --k-text-muted: #5b626b;
73
+ --k-text-faint: #707782;
74
+ --k-brand-contrast: #ffffff;
75
+ --k-positive: #168257;
76
+ --k-caution: #8a5a00;
77
+ --k-negative: #c83b3b;
78
+ --k-neutral: #5f6975;
79
+ --k-active: #3f6fd6;
80
80
  }
81
81
 
82
82
 
83
83
  .survey-workbench-embed.theme-survey{
84
- --k-bg: var(--k-bg, #06080b);
85
- --k-brand: var(--k-brand, #5ce0c6);
86
- --k-text-faint: var(--k-text-faint, #6f8095);
84
+ --k-bg: #06080b;
85
+ --k-brand: #5ce0c6;
86
+ --k-text-faint: #6f8095;
87
87
  }
88
88
 
89
89
  .survey-workbench-embed.theme-console{
90
- --k-font-ui: var(--k-font-ui, "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif);
91
- --k-font-display: var(--k-font-display, "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif);
92
- --k-radius-sm: var(--k-radius-sm, 0);
93
- --k-radius-md: var(--k-radius-md, 0);
94
- --k-brand: var(--k-brand, #c9ff4a);
95
- --k-brand-contrast: var(--k-brand-contrast, #11120f);
96
- --k-bg: var(--k-bg, #11120f);
97
- --k-panel: var(--k-panel, #191b16);
98
- --k-panel-raised: var(--k-panel-raised, #20231e);
99
- --k-line: var(--k-line, #3a4035);
100
- --k-line-strong: var(--k-line-strong, #8ea36e);
101
- --k-text: var(--k-text, #f2f0e8);
102
- --k-text-muted: var(--k-text-muted, #a6ab9c);
103
- --k-text-faint: var(--k-text-faint, #747a6c);
104
- --k-positive: var(--k-positive, #a6d37b);
105
- --k-caution: var(--k-caution, #e8c15f);
106
- --k-negative: var(--k-negative, #ee776f);
107
- --k-active: var(--k-active, #84d8c8);
90
+ --k-font-ui: "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif;
91
+ --k-font-display: "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif;
92
+ --k-radius-sm: 0;
93
+ --k-radius-md: 0;
94
+ --k-brand: #c9ff4a;
95
+ --k-brand-contrast: #11120f;
96
+ --k-bg: #11120f;
97
+ --k-panel: #191b16;
98
+ --k-panel-raised: #20231e;
99
+ --k-line: #3a4035;
100
+ --k-line-strong: #8ea36e;
101
+ --k-text: #f2f0e8;
102
+ --k-text-muted: #a6ab9c;
103
+ --k-text-faint: #747a6c;
104
+ --k-positive: #a6d37b;
105
+ --k-caution: #e8c15f;
106
+ --k-negative: #ee776f;
107
+ --k-active: #84d8c8;
108
108
  }
109
109
 
110
110
  .survey-workbench-embed.theme-flow{
111
- --k-brand: var(--k-brand, #2f88a6);
111
+ --k-brand: #2f88a6;
112
112
  }
113
113
 
114
114
  .survey-workbench-embed.theme-surface{
115
- --k-brand: var(--k-brand, #14a37a);
115
+ --k-brand: #14a37a;
116
116
  }
117
117
 
118
118
  .survey-workbench-embed[data-theme="light"].theme-survey,
119
119
  .survey-workbench-embed[data-theme="light"] .theme-survey{
120
- --k-brand: var(--k-brand, #16806f);
120
+ --k-brand: #16806f;
121
121
  }
122
122
 
123
123
  .survey-workbench-embed[data-theme="light"].theme-console,
124
124
  .survey-workbench-embed[data-theme="light"] .theme-console{
125
- --k-brand: var(--k-brand, #6c9400);
126
- --k-brand-contrast: var(--k-brand-contrast, #ffffff);
127
- --k-bg: var(--k-bg, #f3f5eb);
128
- --k-panel: var(--k-panel, #fbfcf7);
129
- --k-panel-raised: var(--k-panel-raised, #eef2e6);
130
- --k-line: var(--k-line, #ccd5bf);
131
- --k-line-strong: var(--k-line-strong, #8fa36f);
132
- --k-text: var(--k-text, #1e2319);
133
- --k-text-muted: var(--k-text-muted, #596250);
134
- --k-text-faint: var(--k-text-faint, #77816d);
135
- --k-positive: var(--k-positive, #2f7d32);
136
- --k-caution: var(--k-caution, #8a6500);
137
- --k-negative: var(--k-negative, #b93a36);
138
- --k-active: var(--k-active, #247f75);
125
+ --k-brand: #6c9400;
126
+ --k-brand-contrast: #ffffff;
127
+ --k-bg: #f3f5eb;
128
+ --k-panel: #fbfcf7;
129
+ --k-panel-raised: #eef2e6;
130
+ --k-line: #ccd5bf;
131
+ --k-line-strong: #8fa36f;
132
+ --k-text: #1e2319;
133
+ --k-text-muted: #596250;
134
+ --k-text-faint: #77816d;
135
+ --k-positive: #2f7d32;
136
+ --k-caution: #8a6500;
137
+ --k-negative: #b93a36;
138
+ --k-active: #247f75;
139
139
  }
140
140
 
141
141
  .survey-workbench-embed[data-theme="light"].theme-flow,
142
142
  .survey-workbench-embed[data-theme="light"] .theme-flow{
143
- --k-brand: var(--k-brand, #1f6f88);
143
+ --k-brand: #1f6f88;
144
144
  }
145
145
 
146
146
  .survey-workbench-embed[data-theme="light"].theme-surface,
147
147
  .survey-workbench-embed[data-theme="light"] .theme-surface{
148
- --k-brand: var(--k-brand, #0f6b52);
148
+ --k-brand: #0f6b52;
149
149
  }
150
150
 
151
151
 
@@ -399,6 +399,17 @@
399
399
  transition: width 0.35s cubic-bezier(0.2, 0.7, 0.3, 1);
400
400
  }
401
401
 
402
+ /* `.progress` is a generic class name, and a host page may well have its own —
403
+ @kontourai/ui ships an unscoped `.progress span { background: linear-gradient(…) }`
404
+ that painted a gradient straight over this element's text in an embed. The
405
+ embed cannot claim the name, but it must not leave its own descendants
406
+ unstyled and open to a host rule; state the presentation explicitly. */
407
+ .survey-workbench-embed .progress .ptext{
408
+ display: inline;
409
+ height: auto;
410
+ background: none;
411
+ }
412
+
402
413
  .survey-workbench-embed .ptext{
403
414
  font-size: 13px;
404
415
  color: var(--k-muted);
@@ -3,7 +3,7 @@ import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
5
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
6
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
6
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
7
7
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
8
8
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
9
9
  export { validateReviewSessionEventsForSnapshot, type ReviewSessionReplayIssue, type ReviewSessionReplayIssueCode, } from "./review-session-replay.js";
@@ -1,6 +1,6 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
2
  import { assertReviewResolutionConsistency } from "../producer-discipline.js";
3
- import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
+ import { candidateForDecision, keepActionDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
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";
@@ -8,7 +8,7 @@ import { reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
9
  import { humanizeIdentifier } from "./review-presentation.js";
10
10
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
11
- export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
11
+ export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
12
12
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
13
13
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
14
14
  export { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
@@ -475,11 +475,19 @@ function fieldCardState(item, decision) {
475
475
  return "kept";
476
476
  return "review";
477
477
  }
478
- function chipLabel(state) {
478
+ /**
479
+ * The chip copy for a decided field. An item with no current value has nothing
480
+ * to "keep": both the kept and rejected outcomes leave it unset, and for such an
481
+ * item the recorded decision genuinely does not distinguish "leave unset" from
482
+ * "leave unset, and the suggestion was wrong" (both are `reject-proposed` — see
483
+ * {@link keepActionDecision}). Saying "Kept — flagged wrong" there would claim a
484
+ * distinction the record does not hold, so the chip states only what happened.
485
+ */
486
+ function chipLabel(state, hasCurrentValue) {
479
487
  switch (state) {
480
488
  case "accepted": return "Accepted";
481
- case "kept": return "Kept current";
482
- case "rejected": return "Kept — flagged wrong";
489
+ case "kept": return hasCurrentValue ? "Kept current" : "Left unset";
490
+ case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : "Left unset";
483
491
  case "could-not-confirm": return "Could not confirm";
484
492
  default: return "Needs review";
485
493
  }
@@ -607,6 +615,11 @@ function renderFieldCard(item, session, presentationAdapter) {
607
615
  const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
608
616
  const kind = hasCurrentValue ? "Update" : "New";
609
617
  const keepLabel = hasCurrentValue ? "Keep current" : "Leave unset";
618
+ // Only render controls whose decision this item can actually record. Every
619
+ // decision resolves to a candidate role, so a control routed at a role the
620
+ // item does not carry cannot emit anything — it used to throw mid-click and
621
+ // leave the queue unrenderable (kontourai/survey#203).
622
+ const keepDecision = keepActionDecision(item, false);
610
623
  const editedValue = session.editedValuesByItemName?.[item.metadata.name];
611
624
  const proposedPresentationText = proposed
612
625
  ? buildReviewCandidatePresentation(item, proposed, presentationAdapter, presentation.targetLabel).valueText
@@ -632,23 +645,23 @@ function renderFieldCard(item, session, presentationAdapter) {
632
645
  <div class="frow1">
633
646
  <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
634
647
  <span class="fkind">${kind}</span>
635
- <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state)}</span>
648
+ <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state, hasCurrentValue)}</span>
636
649
  </div>
637
650
  ${proposed
638
651
  ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
639
652
  : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
640
653
  ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
641
654
  <div class="decide">
642
- <button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>
643
- <button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
655
+ ${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
656
+ ${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
644
657
  <label class="wrong">
645
658
  <input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
646
659
  Suggestion was wrong
647
660
  </label>
648
- <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>
661
+ <button class="btn unconfirmed" type="button" data-testid="could-not-confirm" data-item-name="${escapeHtml(item.metadata.name)}">Could not confirm</button>` : ""}
649
662
  </div>
650
663
  <div class="decided">
651
- <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state)}</span>
664
+ <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state, hasCurrentValue)}</span>
652
665
  <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
653
666
  </div>
654
667
  ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
@@ -1070,7 +1083,16 @@ function createReviewWorkbenchController(root, startState, options) {
1070
1083
  const persistEvents = () => {
1071
1084
  eventStore?.save(session, events);
1072
1085
  };
1073
- const appendEvent = (eventType, itemName, dataOverride) => {
1086
+ /**
1087
+ * Builds the session event for a state WITHOUT committing anything. Callers
1088
+ * that also mutate the session build the event first and commit only once it
1089
+ * exists: a decision the item cannot represent must leave the workbench
1090
+ * exactly as it was, not half-applied and unrenderable (kontourai/survey#203).
1091
+ */
1092
+ const buildEventForSession = (sessionForEvent, eventType, itemName, dataOverride) => {
1093
+ // Shadow the controller's mutable `session` so this builder can only read
1094
+ // the state it was handed.
1095
+ const session = sessionForEvent;
1074
1096
  const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
1075
1097
  const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
1076
1098
  const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
@@ -1089,83 +1111,71 @@ function createReviewWorkbenchController(root, startState, options) {
1089
1111
  ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
1090
1112
  : { workbenchDecision: decision, ...(attemptEvidenceIds?.length ? { attemptEvidenceIds } : {}) })
1091
1113
  : undefined;
1092
- events = [
1093
- ...events,
1094
- buildReviewSessionEvent(session, {
1095
- sessionName: defaultReviewSessionName,
1096
- sequence: events.length + 1,
1097
- eventType,
1098
- occurredAt: session.reviewedAt,
1099
- reviewItemName: itemName,
1100
- reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
1101
- candidateId: candidate?.id,
1102
- status: definition?.status,
1103
- ...(decision === "could-not-confirm"
1104
- ? {
1105
- resolution: "could_not_confirm",
1106
- resolutionReason: note?.trim(),
1107
- ...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
1108
- }
1109
- : {}),
1110
- rationale: note,
1111
- data: dataOverride ?? defaultData,
1112
- }),
1113
- ];
1114
+ return buildReviewSessionEvent(session, {
1115
+ sessionName: defaultReviewSessionName,
1116
+ sequence: events.length + 1,
1117
+ eventType,
1118
+ occurredAt: session.reviewedAt,
1119
+ reviewItemName: itemName,
1120
+ reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
1121
+ candidateId: candidate?.id,
1122
+ status: definition?.status,
1123
+ ...(decision === "could-not-confirm"
1124
+ ? {
1125
+ resolution: "could_not_confirm",
1126
+ resolutionReason: note?.trim(),
1127
+ ...(attemptEvidenceIds ? { attemptEvidenceIds: [...attemptEvidenceIds] } : {}),
1128
+ }
1129
+ : {}),
1130
+ rationale: note,
1131
+ data: dataOverride ?? defaultData,
1132
+ });
1114
1133
  };
1115
- const applySessionUpdate = (update) => {
1116
- session = update(session);
1134
+ /** Commits a session update and its event together, or neither. */
1135
+ const commitSessionUpdate = (nextSession, eventType, itemName, dataOverride) => {
1136
+ const event = buildEventForSession(nextSession, eventType, itemName, dataOverride);
1137
+ session = nextSession;
1138
+ events = [...events, event];
1139
+ persistEvents();
1117
1140
  };
1118
1141
  const setDecision = (itemName, decision, rawEditedValue) => {
1119
1142
  if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
1120
1143
  throw new Error("Could not confirm requires a non-empty reason.");
1121
1144
  }
1122
- applySessionUpdate((current) => {
1123
- const item = current.items.find((entry) => entry.metadata.name === itemName);
1124
- const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
1125
- const originalText = proposed ? formatValue(proposed.value) : undefined;
1126
- const nextEditedValuesByItemName = { ...current.editedValuesByItemName };
1127
- if (decision === "accept-proposed" && rawEditedValue !== undefined && rawEditedValue !== originalText) {
1128
- nextEditedValuesByItemName[itemName] = rawEditedValue;
1129
- }
1130
- else {
1131
- delete nextEditedValuesByItemName[itemName];
1132
- }
1133
- return {
1134
- ...current,
1135
- activeItemName: itemName,
1136
- decisionsByItemName: { ...current.decisionsByItemName, [itemName]: decision },
1137
- editedValuesByItemName: nextEditedValuesByItemName,
1138
- };
1139
- });
1140
- appendEvent("decision-changed", itemName);
1141
- persistEvents();
1145
+ const item = session.items.find((entry) => entry.metadata.name === itemName);
1146
+ const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
1147
+ const originalText = proposed ? formatValue(proposed.value) : undefined;
1148
+ const nextEditedValuesByItemName = { ...session.editedValuesByItemName };
1149
+ if (decision === "accept-proposed" && rawEditedValue !== undefined && rawEditedValue !== originalText) {
1150
+ nextEditedValuesByItemName[itemName] = rawEditedValue;
1151
+ }
1152
+ else {
1153
+ delete nextEditedValuesByItemName[itemName];
1154
+ }
1155
+ commitSessionUpdate({
1156
+ ...session,
1157
+ activeItemName: itemName,
1158
+ decisionsByItemName: { ...session.decisionsByItemName, [itemName]: decision },
1159
+ editedValuesByItemName: nextEditedValuesByItemName,
1160
+ }, "decision-changed", itemName);
1142
1161
  };
1143
1162
  const clearDecision = (itemName) => {
1144
- applySessionUpdate((current) => {
1145
- const remainingDecisions = { ...current.decisionsByItemName };
1146
- delete remainingDecisions[itemName];
1147
- const remainingEdits = { ...current.editedValuesByItemName };
1148
- delete remainingEdits[itemName];
1149
- const remainingAttemptEvidenceIds = { ...current.attemptEvidenceIdsByItemName };
1150
- delete remainingAttemptEvidenceIds[itemName];
1151
- return {
1152
- ...current,
1153
- activeItemName: itemName,
1154
- decisionsByItemName: remainingDecisions,
1155
- editedValuesByItemName: remainingEdits,
1156
- attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
1157
- };
1158
- });
1159
- appendEvent("decision-changed", itemName, { workbenchDecision: null });
1160
- persistEvents();
1163
+ const remainingDecisions = { ...session.decisionsByItemName };
1164
+ delete remainingDecisions[itemName];
1165
+ const remainingEdits = { ...session.editedValuesByItemName };
1166
+ delete remainingEdits[itemName];
1167
+ const remainingAttemptEvidenceIds = { ...session.attemptEvidenceIdsByItemName };
1168
+ delete remainingAttemptEvidenceIds[itemName];
1169
+ commitSessionUpdate({
1170
+ ...session,
1171
+ activeItemName: itemName,
1172
+ decisionsByItemName: remainingDecisions,
1173
+ editedValuesByItemName: remainingEdits,
1174
+ attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
1175
+ }, "decision-changed", itemName, { workbenchDecision: null });
1161
1176
  };
1162
1177
  const updateReviewerNote = (itemName, note) => {
1163
- applySessionUpdate((current) => ({
1164
- ...current,
1165
- notesByItemName: { ...current.notesByItemName, [itemName]: note },
1166
- }));
1167
- appendEvent("note-changed", itemName);
1168
- persistEvents();
1178
+ commitSessionUpdate({ ...session, notesByItemName: { ...session.notesByItemName, [itemName]: note } }, "note-changed", itemName);
1169
1179
  };
1170
1180
  const controller = {
1171
1181
  currentSession: () => session,
@@ -1252,7 +1262,13 @@ function bindFieldCardInteractions(root, controller) {
1252
1262
  const descriptor = controller
1253
1263
  .currentSession()
1254
1264
  .items.find((entry) => entry.metadata.name === itemName)?.spec.valueDescriptor;
1255
- const error = validateProposedValue(descriptor, input?.value ?? "");
1265
+ // Validate the reviewer's edit only when there IS an editor to have edited.
1266
+ // A non-editable item (`spec.editable === false`, e.g. every envelope-imported
1267
+ // item) renders no editor, so there is no reviewer input to check — validating
1268
+ // the absent editor's "" value blocked the decision on every typed field, and
1269
+ // wrote the reason into an error slot inside the suppressed editor, so the
1270
+ // reviewer saw nothing at all (kontourai/survey#201).
1271
+ const error = input ? validateProposedValue(descriptor, input.value) : undefined;
1256
1272
  const errorEl = field?.querySelector("[data-testid='value-error']");
1257
1273
  if (error) {
1258
1274
  // Block the decision and surface the reason inline; the transient DOM
@@ -1277,7 +1293,15 @@ function bindFieldCardInteractions(root, controller) {
1277
1293
  const itemName = button.dataset.itemName ?? "";
1278
1294
  const field = button.closest("[data-testid='review-field']");
1279
1295
  const wrong = field?.querySelector("[data-testid='wrong-toggle']");
1280
- const decision = wrong?.checked ? "reject-proposed" : "keep-current";
1296
+ const item = controller.currentSession().items.find((entry) => entry.metadata.name === itemName);
1297
+ // Route by what this item can record, not by the button's identity: on an
1298
+ // item with no current candidate this control reads "Leave unset", and
1299
+ // "keep the current candidate" is not a decision it can carry
1300
+ // (kontourai/survey#203).
1301
+ const decision = item ? keepActionDecision(item, wrong?.checked ?? false) : undefined;
1302
+ if (!decision) {
1303
+ return;
1304
+ }
1281
1305
  controller.setDecision(itemName, decision);
1282
1306
  controller.renderCurrentState();
1283
1307
  });
@@ -248,6 +248,17 @@ h1, h2, h3, p {
248
248
  transition: width 0.35s cubic-bezier(0.2, 0.7, 0.3, 1);
249
249
  }
250
250
 
251
+ /* `.progress` is a generic class name, and a host page may well have its own —
252
+ @kontourai/ui ships an unscoped `.progress span { background: linear-gradient(…) }`
253
+ that painted a gradient straight over this element's text in an embed. The
254
+ embed cannot claim the name, but it must not leave its own descendants
255
+ unstyled and open to a host rule; state the presentation explicitly. */
256
+ .progress .ptext {
257
+ display: inline;
258
+ height: auto;
259
+ background: none;
260
+ }
261
+
251
262
  .ptext {
252
263
  font-size: 13px;
253
264
  color: var(--k-muted);