@kontourai/survey 2.2.2 → 2.2.4

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.
package/README.md CHANGED
@@ -169,12 +169,20 @@ The embedded stylesheet is scoped to `.survey-workbench-embed` and bundles Conso
169
169
 
170
170
  ### Theme it as your own brand
171
171
 
172
- The bundled `--k-*` token defaults are emitted in overridable form
173
- (`--k-brand: var(--k-brand, <default>)`), so a host brand is authoritative: set
174
- any `--k-*` token on an ancestor of the embed (or inline on the embed element,
175
- or the host element of the web component) and it propagates in — no need to
176
- out-specificity the embed's own selectors. Unset tokens keep their default, so
177
- you only declare what you re-brand.
172
+ The bundled stylesheet carries a literal default for every `--k-*` token on the
173
+ embed element itself, so the workbench looks right in a host that declares no
174
+ tokens at all. To re-brand it, declare the tokens you want **on the embed
175
+ element** — a rule matching `.survey-workbench-embed`, or an inline `style`
176
+ attribute. Tokens you leave alone keep their default, so you only declare what
177
+ you re-brand.
178
+
179
+ Setting `--k-*` on an *ancestor* of the embed does not reach it: the embed
180
+ declares those tokens on its own root, and a declaration on an element always
181
+ beats a value inherited from an ancestor. If you want the embed to inherit a
182
+ token layer your page already publishes at `:root`, use the web component
183
+ (`<survey-review-workbench>`) — its defaults sit on the shadow `:host`, so host
184
+ tokens and inline styles propagate through — or restate the tokens on the embed
185
+ element as below.
178
186
 
179
187
  ```css
180
188
  /* Your app's own palette drives the workbench — no Kontour branding. */
@@ -74,6 +74,23 @@ export declare function currentReviewItem(session: ReviewQueueSessionState): Rev
74
74
  export declare function deriveQueueRowStatus(item: ReviewItem, session: ReviewQueueSessionState): ReviewQueueRowStatus;
75
75
  export declare function nextUnresolvedItemName(session: ReviewQueueSessionState): string | undefined;
76
76
  export declare function reviewSessionSummary(session: ReviewQueueSessionState): ReviewSessionSummary;
77
+ /**
78
+ * The decision the field card's "keep" control records for a ReviewItem, or
79
+ * `undefined` when the item cannot represent that action at all.
80
+ *
81
+ * Every workbench decision resolves to a candidate role (see
82
+ * {@link workbenchDecisionDefinitions}), so a decision whose role the item does
83
+ * not carry is not recordable — `keep-current` on an item that has no `current`
84
+ * candidate is the case that matters, because that is exactly how an
85
+ * envelope-imported item is modelled (one `proposed` candidate, nothing prior).
86
+ * The card already labels that control "Leave unset" rather than "Keep current";
87
+ * this returns the decision that means the same thing and IS representable:
88
+ * `reject-proposed` — the proposed value is not applied and nothing is set.
89
+ *
90
+ * Deliberately NOT solved by synthesising an empty `current` candidate: that
91
+ * would invent a prior value, with provenance, that the source never had.
92
+ */
93
+ export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boolean): ReviewWorkbenchDecision | undefined;
77
94
  export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
78
95
  /**
79
96
  * The value that should actually be applied for a decision: the reviewer's inline
@@ -125,6 +125,29 @@ export function reviewSessionSummary(session) {
125
125
  unresolved: 0,
126
126
  });
127
127
  }
128
+ /**
129
+ * The decision the field card's "keep" control records for a ReviewItem, or
130
+ * `undefined` when the item cannot represent that action at all.
131
+ *
132
+ * Every workbench decision resolves to a candidate role (see
133
+ * {@link workbenchDecisionDefinitions}), so a decision whose role the item does
134
+ * not carry is not recordable — `keep-current` on an item that has no `current`
135
+ * candidate is the case that matters, because that is exactly how an
136
+ * envelope-imported item is modelled (one `proposed` candidate, nothing prior).
137
+ * The card already labels that control "Leave unset" rather than "Keep current";
138
+ * this returns the decision that means the same thing and IS representable:
139
+ * `reject-proposed` — the proposed value is not applied and nothing is set.
140
+ *
141
+ * Deliberately NOT solved by synthesising an empty `current` candidate: that
142
+ * would invent a prior value, with provenance, that the source never had.
143
+ */
144
+ export function keepActionDecision(item, flaggedWrong) {
145
+ const hasRole = (role) => item.spec.candidates.some((candidate) => candidate.role === role);
146
+ if (flaggedWrong || !hasRole("current")) {
147
+ return hasRole("proposed") ? "reject-proposed" : undefined;
148
+ }
149
+ return "keep-current";
150
+ }
128
151
  export function candidateForDecision(item, decision) {
129
152
  const definition = workbenchDecisionDefinitions[decision];
130
153
  const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
@@ -6,149 +6,149 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
6
6
  color-scheme: dark;
7
7
 
8
8
  /* structure */
9
- --k-bg: var(--k-bg, #0a0e13);
10
- --k-panel: var(--k-panel, #111824);
11
- --k-panel-raised: var(--k-panel-raised, #16202d);
12
- --k-line: var(--k-line, rgba(150, 180, 210, 0.12));
13
- --k-line-strong: var(--k-line-strong, rgba(150, 180, 210, 0.22));
14
- --k-shadow: var(--k-shadow, 0 26px 60px -42px rgba(0, 0, 0, 0.95));
9
+ --k-bg: #0a0e13;
10
+ --k-panel: #111824;
11
+ --k-panel-raised: #16202d;
12
+ --k-line: rgba(150, 180, 210, 0.12);
13
+ --k-line-strong: rgba(150, 180, 210, 0.22);
14
+ --k-shadow: 0 26px 60px -42px rgba(0, 0, 0, 0.95);
15
15
 
16
16
  /* text */
17
- --k-text: var(--k-text, #eef3f8);
18
- --k-text-muted: var(--k-text-muted, #aebccb);
19
- --k-text-faint: var(--k-text-faint, #72869b);
17
+ --k-text: #eef3f8;
18
+ --k-text-muted: #aebccb;
19
+ --k-text-faint: #72869b;
20
20
 
21
21
  /* brand */
22
- --k-brand: var(--k-brand, #5ce0c6);
23
- --k-brand-contrast: var(--k-brand-contrast, #06080b);
22
+ --k-brand: #5ce0c6;
23
+ --k-brand-contrast: #06080b;
24
24
 
25
25
  /* semantic status scale */
26
- --k-positive: var(--k-positive, #34d399);
27
- --k-caution: var(--k-caution, #f3b14b);
28
- --k-negative: var(--k-negative, #ff6f6f);
29
- --k-neutral: var(--k-neutral, #6f8095);
30
- --k-active: var(--k-active, #7aa2ff);
26
+ --k-positive: #34d399;
27
+ --k-caution: #f3b14b;
28
+ --k-negative: #ff6f6f;
29
+ --k-neutral: #6f8095;
30
+ --k-active: #7aa2ff;
31
31
  --k-positive-soft: color-mix(in oklab, var(--k-positive) 14%, transparent);
32
32
  --k-caution-soft: color-mix(in oklab, var(--k-caution) 14%, transparent);
33
33
  --k-negative-soft: color-mix(in oklab, var(--k-negative) 14%, transparent);
34
34
  --k-active-soft: color-mix(in oklab, var(--k-active) 14%, transparent);
35
35
 
36
36
  /* spacing */
37
- --k-space-1: var(--k-space-1, 4px);
38
- --k-space-2: var(--k-space-2, 8px);
39
- --k-space-3: var(--k-space-3, 12px);
40
- --k-space-4: var(--k-space-4, 16px);
41
- --k-space-5: var(--k-space-5, 24px);
42
- --k-space-6: var(--k-space-6, 32px);
37
+ --k-space-1: 4px;
38
+ --k-space-2: 8px;
39
+ --k-space-3: 12px;
40
+ --k-space-4: 16px;
41
+ --k-space-5: 24px;
42
+ --k-space-6: 32px;
43
43
 
44
44
  /* radius */
45
- --k-radius-sm: var(--k-radius-sm, 9px);
46
- --k-radius-md: var(--k-radius-md, 14px);
45
+ --k-radius-sm: 9px;
46
+ --k-radius-md: 14px;
47
47
 
48
48
  /* type */
49
- --k-text-xs: var(--k-text-xs, 11px);
50
- --k-text-sm: var(--k-text-sm, 12.5px);
51
- --k-text-md: var(--k-text-md, 14px);
52
- --k-text-lg: var(--k-text-lg, 18px);
53
- --k-text-xl: var(--k-text-xl, 22px);
54
- --k-text-2xl: var(--k-text-2xl, clamp(26px, 3.4vw, 38px));
49
+ --k-text-xs: 11px;
50
+ --k-text-sm: 12.5px;
51
+ --k-text-md: 14px;
52
+ --k-text-lg: 18px;
53
+ --k-text-xl: 22px;
54
+ --k-text-2xl: clamp(26px, 3.4vw, 38px);
55
55
 
56
56
  /* fonts */
57
- --k-font-display: var(--k-font-display, "Fraunces", Georgia, "Times New Roman", serif);
58
- --k-font-ui: var(--k-font-ui, "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
59
- --k-font-mono: var(--k-font-mono, "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace);
57
+ --k-font-display: "Fraunces", Georgia, "Times New Roman", serif;
58
+ --k-font-ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
59
+ --k-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
60
60
 
61
61
  /* motion */
62
- --k-ease: var(--k-ease, cubic-bezier(0.22, 1, 0.36, 1));
63
- --k-dur: var(--k-dur, 0.16s);
62
+ --k-ease: cubic-bezier(0.22, 1, 0.36, 1);
63
+ --k-dur: 0.16s;
64
64
  }
65
65
 
66
66
  .survey-workbench-embed[data-theme="light"]{
67
67
  color-scheme: light;
68
- --k-bg: var(--k-bg, #f5f4ef);
69
- --k-panel: var(--k-panel, #ffffff);
70
- --k-panel-raised: var(--k-panel-raised, #fbfaf7);
71
- --k-line: var(--k-line, rgba(36, 40, 46, 0.12));
72
- --k-line-strong: var(--k-line-strong, rgba(36, 40, 46, 0.20));
73
- --k-shadow: var(--k-shadow, 0 18px 50px -30px rgba(0, 0, 0, 0.25));
74
- --k-text: var(--k-text, #202124);
75
- --k-text-muted: var(--k-text-muted, #5b626b);
76
- --k-text-faint: var(--k-text-faint, #707782);
77
- --k-brand-contrast: var(--k-brand-contrast, #ffffff);
78
- --k-positive: var(--k-positive, #168257);
79
- --k-caution: var(--k-caution, #8a5a00);
80
- --k-negative: var(--k-negative, #c83b3b);
81
- --k-neutral: var(--k-neutral, #5f6975);
82
- --k-active: var(--k-active, #3f6fd6);
68
+ --k-bg: #f5f4ef;
69
+ --k-panel: #ffffff;
70
+ --k-panel-raised: #fbfaf7;
71
+ --k-line: rgba(36, 40, 46, 0.12);
72
+ --k-line-strong: rgba(36, 40, 46, 0.20);
73
+ --k-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.25);
74
+ --k-text: #202124;
75
+ --k-text-muted: #5b626b;
76
+ --k-text-faint: #707782;
77
+ --k-brand-contrast: #ffffff;
78
+ --k-positive: #168257;
79
+ --k-caution: #8a5a00;
80
+ --k-negative: #c83b3b;
81
+ --k-neutral: #5f6975;
82
+ --k-active: #3f6fd6;
83
83
  }
84
84
 
85
85
 
86
86
  .survey-workbench-embed.theme-survey{
87
- --k-bg: var(--k-bg, #06080b);
88
- --k-brand: var(--k-brand, #5ce0c6);
89
- --k-text-faint: var(--k-text-faint, #6f8095);
87
+ --k-bg: #06080b;
88
+ --k-brand: #5ce0c6;
89
+ --k-text-faint: #6f8095;
90
90
  }
91
91
 
92
92
  .survey-workbench-embed.theme-console{
93
- --k-font-ui: var(--k-font-ui, "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif);
94
- --k-font-display: var(--k-font-display, "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif);
95
- --k-radius-sm: var(--k-radius-sm, 0);
96
- --k-radius-md: var(--k-radius-md, 0);
97
- --k-brand: var(--k-brand, #c9ff4a);
98
- --k-brand-contrast: var(--k-brand-contrast, #11120f);
99
- --k-bg: var(--k-bg, #11120f);
100
- --k-panel: var(--k-panel, #191b16);
101
- --k-panel-raised: var(--k-panel-raised, #20231e);
102
- --k-line: var(--k-line, #3a4035);
103
- --k-line-strong: var(--k-line-strong, #8ea36e);
104
- --k-text: var(--k-text, #f2f0e8);
105
- --k-text-muted: var(--k-text-muted, #a6ab9c);
106
- --k-text-faint: var(--k-text-faint, #747a6c);
107
- --k-positive: var(--k-positive, #a6d37b);
108
- --k-caution: var(--k-caution, #e8c15f);
109
- --k-negative: var(--k-negative, #ee776f);
110
- --k-active: var(--k-active, #84d8c8);
93
+ --k-font-ui: "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif;
94
+ --k-font-display: "Aptos Narrow", "DIN Condensed", "IBM Plex Sans Condensed", "Gill Sans", sans-serif;
95
+ --k-radius-sm: 0;
96
+ --k-radius-md: 0;
97
+ --k-brand: #c9ff4a;
98
+ --k-brand-contrast: #11120f;
99
+ --k-bg: #11120f;
100
+ --k-panel: #191b16;
101
+ --k-panel-raised: #20231e;
102
+ --k-line: #3a4035;
103
+ --k-line-strong: #8ea36e;
104
+ --k-text: #f2f0e8;
105
+ --k-text-muted: #a6ab9c;
106
+ --k-text-faint: #747a6c;
107
+ --k-positive: #a6d37b;
108
+ --k-caution: #e8c15f;
109
+ --k-negative: #ee776f;
110
+ --k-active: #84d8c8;
111
111
  }
112
112
 
113
113
  .survey-workbench-embed.theme-flow{
114
- --k-brand: var(--k-brand, #2f88a6);
114
+ --k-brand: #2f88a6;
115
115
  }
116
116
 
117
117
  .survey-workbench-embed.theme-surface{
118
- --k-brand: var(--k-brand, #14a37a);
118
+ --k-brand: #14a37a;
119
119
  }
120
120
 
121
121
  .survey-workbench-embed[data-theme="light"].theme-survey,
122
122
  .survey-workbench-embed[data-theme="light"] .theme-survey{
123
- --k-brand: var(--k-brand, #16806f);
123
+ --k-brand: #16806f;
124
124
  }
125
125
 
126
126
  .survey-workbench-embed[data-theme="light"].theme-console,
127
127
  .survey-workbench-embed[data-theme="light"] .theme-console{
128
- --k-brand: var(--k-brand, #6c9400);
129
- --k-brand-contrast: var(--k-brand-contrast, #ffffff);
130
- --k-bg: var(--k-bg, #f3f5eb);
131
- --k-panel: var(--k-panel, #fbfcf7);
132
- --k-panel-raised: var(--k-panel-raised, #eef2e6);
133
- --k-line: var(--k-line, #ccd5bf);
134
- --k-line-strong: var(--k-line-strong, #8fa36f);
135
- --k-text: var(--k-text, #1e2319);
136
- --k-text-muted: var(--k-text-muted, #596250);
137
- --k-text-faint: var(--k-text-faint, #77816d);
138
- --k-positive: var(--k-positive, #2f7d32);
139
- --k-caution: var(--k-caution, #8a6500);
140
- --k-negative: var(--k-negative, #b93a36);
141
- --k-active: var(--k-active, #247f75);
128
+ --k-brand: #6c9400;
129
+ --k-brand-contrast: #ffffff;
130
+ --k-bg: #f3f5eb;
131
+ --k-panel: #fbfcf7;
132
+ --k-panel-raised: #eef2e6;
133
+ --k-line: #ccd5bf;
134
+ --k-line-strong: #8fa36f;
135
+ --k-text: #1e2319;
136
+ --k-text-muted: #596250;
137
+ --k-text-faint: #77816d;
138
+ --k-positive: #2f7d32;
139
+ --k-caution: #8a6500;
140
+ --k-negative: #b93a36;
141
+ --k-active: #247f75;
142
142
  }
143
143
 
144
144
  .survey-workbench-embed[data-theme="light"].theme-flow,
145
145
  .survey-workbench-embed[data-theme="light"] .theme-flow{
146
- --k-brand: var(--k-brand, #1f6f88);
146
+ --k-brand: #1f6f88;
147
147
  }
148
148
 
149
149
  .survey-workbench-embed[data-theme="light"].theme-surface,
150
150
  .survey-workbench-embed[data-theme="light"] .theme-surface{
151
- --k-brand: var(--k-brand, #0f6b52);
151
+ --k-brand: #0f6b52;
152
152
  }
153
153
 
154
154
 
@@ -402,6 +402,17 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
402
402
  transition: width 0.35s cubic-bezier(0.2, 0.7, 0.3, 1);
403
403
  }
404
404
 
405
+ /* \`.progress\` is a generic class name, and a host page may well have its own —
406
+ @kontourai/ui ships an unscoped \`.progress span { background: linear-gradient(…) }\`
407
+ that painted a gradient straight over this element's text in an embed. The
408
+ embed cannot claim the name, but it must not leave its own descendants
409
+ unstyled and open to a host rule; state the presentation explicitly. */
410
+ .survey-workbench-embed .progress .ptext{
411
+ display: inline;
412
+ height: auto;
413
+ background: none;
414
+ }
415
+
405
416
  .survey-workbench-embed .ptext{
406
417
  font-size: 13px;
407
418
  color: var(--k-muted);
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
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",