@kontourai/survey 1.12.0 → 1.14.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.
@@ -38,6 +38,12 @@ export interface CurrentProposedReviewItemInput {
38
38
  readonly current?: string;
39
39
  readonly proposed?: string;
40
40
  };
41
+ /**
42
+ * Whether the reviewer may edit the proposed value inline. Defaults to
43
+ * `true`. Pass `false` for keep/use/reject-only queues (see
44
+ * {@link ReviewItemSpec.editable}).
45
+ */
46
+ readonly editable?: boolean;
41
47
  }
42
48
  /**
43
49
  * Builds the two-candidate current/proposed {@link ReviewItem} envelope that
@@ -38,6 +38,7 @@ export function currentProposedReviewItem(input) {
38
38
  ...(selectedCandidateId ? { selectedCandidateId } : {}),
39
39
  ...(input.rationale ? { rationale: input.rationale } : {}),
40
40
  ...(input.producerPolicy ? { producerPolicy: input.producerPolicy } : {}),
41
+ ...(input.editable === false ? { editable: false } : {}),
41
42
  projection: { ...input.projection, candidateSetId },
42
43
  },
43
44
  status: {
@@ -1,7 +1,7 @@
1
1
  export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewStatus, SurveyInput, } from "./types.js";
2
2
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
- export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
4
+ export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
5
5
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
6
6
  export type { CandidateReviewRecordInput, SurveyClaimRecord, SurveyInputBuilderArgs, SurveyObservationInput, } from "./builder.js";
7
7
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
@@ -105,6 +105,28 @@ export interface ReviewCandidate {
105
105
  projection?: SurveyRecordProjectionHint;
106
106
  producer?: Record<string, unknown>;
107
107
  }
108
+ /**
109
+ * Well-known neutral value-type vocabulary for {@link ReviewValueDescriptor}.
110
+ * Survey defines NO field-schema system of its own; this deliberately MIRRORS
111
+ * the shape an upstream field-schema owner already uses (e.g. traverse's
112
+ * `TargetFieldSchema.type` / `ExtractionProposal.valueType`), so a producer can
113
+ * carry a reviewed field's declared type down to the review UI WITHOUT Survey
114
+ * importing that owner's package (structural match, zero coupling).
115
+ */
116
+ export type ReviewValueType = "string" | "number" | "boolean" | "date" | "enum" | "array" | "object";
117
+ /**
118
+ * Optional, producer-supplied descriptor of a reviewed field's declared value
119
+ * shape. Purely descriptive: the workbench uses it ONLY to pick a typed editor
120
+ * (an enum `<select>`, a date/number input) and to validate a reviewer's edit
121
+ * before "Use proposed". Survey never re-derives, coerces, or overrides a
122
+ * candidate's value from it — a producer can still surface an out-of-shape
123
+ * candidate, which is exactly what a typed reviewer catches.
124
+ */
125
+ export interface ReviewValueDescriptor {
126
+ type: ReviewValueType;
127
+ /** Allowed values — meaningful with `type: "enum"`; ignored otherwise. */
128
+ enumValues?: string[];
129
+ }
108
130
  export interface ReviewItemSpec {
109
131
  target: string;
110
132
  candidates: ReviewCandidate[];
@@ -113,6 +135,24 @@ export interface ReviewItemSpec {
113
135
  rationale?: string;
114
136
  producerPolicy?: ProducerPolicy;
115
137
  projection?: SurveyRecordProjectionHint;
138
+ /**
139
+ * Optional neutral descriptor of the reviewed field's declared value type.
140
+ * When present, the workbench renders a typed editor and validates a
141
+ * reviewer's inline edit against it before accepting the proposed value.
142
+ * Absent → a plain text editor with no validation (today's behavior).
143
+ */
144
+ valueDescriptor?: ReviewValueDescriptor;
145
+ /**
146
+ * Whether the reviewer may edit the proposed value inline before accepting
147
+ * it. Defaults to `true` (today's behavior). Set `false` for queues where an
148
+ * edited value is meaningless or must not be accepted — an approve/keep
149
+ * identity decision, a value the producer will re-derive, a review that only
150
+ * chooses between the two candidates as given. The workbench then renders no
151
+ * editor at all: the decision is keep-current / use-proposed / reject only,
152
+ * and `effectiveValue` is always the selected candidate's own value. This is
153
+ * enforcement (the affordance is absent), not a cosmetic hide.
154
+ */
155
+ editable?: boolean;
116
156
  }
117
157
  export interface ReviewItemStatus {
118
158
  observedCandidateCount?: number;
@@ -197,7 +197,15 @@ export function buildReviewSessionEvents(session, sessionName = defaultReviewSes
197
197
  const candidate = candidateForDecision(item, decision);
198
198
  const definition = workbenchDecisionDefinitions[decision];
199
199
  const reviewDecisionName = `${item.metadata.name}-${decision}`;
200
- const data = { workbenchDecision: decision };
200
+ // Carry the reviewer's inline edit in the event itself (accept-proposed
201
+ // only — it is the sole decision where an edited value is meaningful), so
202
+ // that replaying snapshot + events reconstructs editedValuesByItemName and
203
+ // the server apply boundary derives effectiveValue from it. Without this
204
+ // the edit lives only in browser state and never survives replay.
205
+ const editedValue = decision === "accept-proposed" ? session.editedValuesByItemName?.[item.metadata.name] : undefined;
206
+ const data = editedValue !== undefined
207
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
208
+ : { workbenchDecision: decision };
201
209
  events.push(buildReviewSessionEvent(session, {
202
210
  sessionName,
203
211
  sequence: events.length + 1,
@@ -252,24 +260,49 @@ export function replayReviewSessionEvents(startState, events) {
252
260
  }
253
261
  if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
254
262
  && event.spec.reviewItemName) {
263
+ const itemName = event.spec.reviewItemName;
255
264
  if (isClearedWorkbenchDecisionEvent(event)) {
256
- const { [event.spec.reviewItemName]: _removed, ...remainingDecisions } = session.decisionsByItemName;
257
- return { ...session, decisionsByItemName: remainingDecisions };
265
+ const { [itemName]: _removedDecision, ...remainingDecisions } = session.decisionsByItemName;
266
+ const { [itemName]: _removedEdit, ...remainingEdits } = session.editedValuesByItemName ?? {};
267
+ return { ...session, decisionsByItemName: remainingDecisions, editedValuesByItemName: remainingEdits };
258
268
  }
259
269
  const decision = workbenchDecisionFromEvent(event);
260
- return decision
261
- ? {
262
- ...session,
263
- decisionsByItemName: {
264
- ...session.decisionsByItemName,
265
- [event.spec.reviewItemName]: decision,
266
- },
267
- }
268
- : session;
270
+ if (!decision) {
271
+ return session;
272
+ }
273
+ // Restore the inline edit the event carried (accept-proposed only); any
274
+ // other decision, or an accept with no carried edit, clears a stale edit
275
+ // for this item so effectiveValue can't fall back to an edit the
276
+ // reviewer moved away from.
277
+ const editedValue = workbenchEditedValueFromEvent(event);
278
+ const editedValuesByItemName = { ...session.editedValuesByItemName };
279
+ if (decision === "accept-proposed" && editedValue !== undefined) {
280
+ editedValuesByItemName[itemName] = editedValue;
281
+ }
282
+ else {
283
+ delete editedValuesByItemName[itemName];
284
+ }
285
+ return {
286
+ ...session,
287
+ decisionsByItemName: {
288
+ ...session.decisionsByItemName,
289
+ [itemName]: decision,
290
+ },
291
+ editedValuesByItemName,
292
+ };
269
293
  }
270
294
  return session;
271
295
  }, startState);
272
296
  }
297
+ /**
298
+ * Extracts a decision event's carried inline edit, or `undefined` when the
299
+ * event carries none. The edit rides `data.workbenchEditedValue`.
300
+ */
301
+ function workbenchEditedValueFromEvent(event) {
302
+ return event.spec.data && "workbenchEditedValue" in event.spec.data
303
+ ? event.spec.data.workbenchEditedValue
304
+ : undefined;
305
+ }
273
306
  /**
274
307
  * Detects the explicit "clear this ReviewItem's decision" replay signal (emitted
275
308
  * by the workbench's "Change" / undo control): a decision event whose
@@ -558,7 +558,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
558
558
  margin-top: 8px;
559
559
  }
560
560
 
561
- .survey-workbench-embed .editrow input{
561
+ .survey-workbench-embed .editrow input,
562
+ .survey-workbench-embed .editrow select{
562
563
  flex: 1;
563
564
  min-width: 0;
564
565
  font-size: 13.5px;
@@ -569,7 +570,8 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
569
570
  padding: 6px 9px;
570
571
  }
571
572
 
572
- .survey-workbench-embed .editrow input:focus-visible{
573
+ .survey-workbench-embed .editrow input:focus-visible,
574
+ .survey-workbench-embed .editrow select:focus-visible{
573
575
  outline: 2px solid var(--k-brand);
574
576
  outline-offset: 1px;
575
577
  border-color: var(--k-brand);
@@ -581,6 +583,20 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
581
583
  white-space: nowrap;
582
584
  }
583
585
 
586
+ /* Validation message for a typed proposed-value edit (hidden until the mount
587
+ handler blocks a "Use proposed" on an out-of-shape value). */
588
+ .survey-workbench-embed .verr{
589
+ display: block;
590
+ margin-top: 5px;
591
+ font-size: 11.5px;
592
+ font-weight: 600;
593
+ color: var(--k-negative);
594
+ }
595
+
596
+ .survey-workbench-embed .verr[hidden]{
597
+ display: none;
598
+ }
599
+
584
600
  /* confidence + provenance */
585
601
 
586
602
  .survey-workbench-embed .prov{
@@ -555,7 +555,8 @@
555
555
  margin-top: 8px;
556
556
  }
557
557
 
558
- .survey-workbench-embed .editrow input{
558
+ .survey-workbench-embed .editrow input,
559
+ .survey-workbench-embed .editrow select{
559
560
  flex: 1;
560
561
  min-width: 0;
561
562
  font-size: 13.5px;
@@ -566,7 +567,8 @@
566
567
  padding: 6px 9px;
567
568
  }
568
569
 
569
- .survey-workbench-embed .editrow input:focus-visible{
570
+ .survey-workbench-embed .editrow input:focus-visible,
571
+ .survey-workbench-embed .editrow select:focus-visible{
570
572
  outline: 2px solid var(--k-brand);
571
573
  outline-offset: 1px;
572
574
  border-color: var(--k-brand);
@@ -578,6 +580,20 @@
578
580
  white-space: nowrap;
579
581
  }
580
582
 
583
+ /* Validation message for a typed proposed-value edit (hidden until the mount
584
+ handler blocks a "Use proposed" on an out-of-shape value). */
585
+ .survey-workbench-embed .verr{
586
+ display: block;
587
+ margin-top: 5px;
588
+ font-size: 11.5px;
589
+ font-weight: 600;
590
+ color: var(--k-negative);
591
+ }
592
+
593
+ .survey-workbench-embed .verr[hidden]{
594
+ display: none;
595
+ }
596
+
581
597
  /* confidence + provenance */
582
598
 
583
599
  .survey-workbench-embed .prov{
@@ -1,7 +1,7 @@
1
1
  import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type ReviewWorkbenchState } from "./review-queue-session.js";
2
2
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
- import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent } from "../review-resource.js";
4
+ import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
5
  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
6
  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";
7
7
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
@@ -179,6 +179,16 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
179
179
  export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
180
180
  readonly presentationAdapter?: ReviewPresentationAdapter;
181
181
  }): string;
182
+ /**
183
+ * Validates a reviewer's raw (string) edit of a proposed value against the
184
+ * field's neutral {@link ReviewValueDescriptor}, returning a human-readable
185
+ * error message when the value violates the declared type/enum constraint, or
186
+ * `undefined` when it is acceptable — including when there is no descriptor or
187
+ * the type carries no single-line constraint (string/array/object). This is a
188
+ * FORMAT check only: it never coerces or rewrites the value (the workbench
189
+ * stores the reviewer's string edit unchanged, as it did before typed editors).
190
+ */
191
+ export declare function validateProposedValue(descriptor: ReviewValueDescriptor | undefined, rawValue: string): string | undefined;
182
192
  export declare function mountReviewWorkbench(root: HTMLElement, startState?: ReviewQueueSessionState | ReviewWorkbenchState, options?: MountReviewWorkbenchOptions): void;
183
193
  export declare function browserReviewWorkbenchStartState(): ReviewQueueSessionState | ReviewWorkbenchState | undefined;
184
194
  declare global {
@@ -567,7 +567,7 @@ function renderDiffRow(item, current, proposed, targetLabel, decided, effectiveP
567
567
  <div class="val proposed">
568
568
  <div class="vlbl">Proposed</div>
569
569
  <div class="vtext" data-value data-testid="proposed-value">${escapeHtml(effectiveProposedText)}</div>
570
- ${decided ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
570
+ ${decided || item.spec.editable === false ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
571
571
  </div>
572
572
  </div>
573
573
  `;
@@ -581,25 +581,131 @@ function renderDiffRow(item, current, proposed, targetLabel, decided, effectiveP
581
581
  * from an enum's allowed values, or date/number/boolean inputs — driven by an
582
582
  * optional neutral value-type descriptor, without touching the rest of the
583
583
  * field-card renderer. Survey deliberately has no value-type/enum system of its
584
- * own (that belongs to the upstream field-schema owner), so today this always
585
- * renders a plain text input, matching the approved mockup.
584
+ * own (that belongs to the upstream field-schema owner) — but a producer MAY
585
+ * carry a field's declared shape down via the neutral
586
+ * {@link ReviewValueDescriptor} on the item spec. When present, this renders a
587
+ * typed control (an enum `<select>`, a date/number input, a true/false select)
588
+ * and a validation-error slot the mount handler populates before "Use
589
+ * proposed"; when absent, it renders the plain text input, matching the
590
+ * approved mockup.
586
591
  */
587
592
  function renderProposedValueEditor(item, proposed, targetLabel) {
593
+ const descriptor = item.spec.valueDescriptor;
588
594
  const valueText = formatValue(proposed.value);
589
- return `
590
- <div class="editrow">
591
- <input
592
- type="text"
595
+ const commonAttrs = `
593
596
  class="proposed-value-input"
594
597
  data-testid="edit-proposed-value"
595
598
  data-item-name="${escapeHtml(item.metadata.name)}"
596
- value="${escapeHtml(valueText)}"
597
- aria-label="Edit proposed ${escapeHtml(targetLabel)}"
598
- >
599
- <span class="ehint">editable</span>
599
+ aria-label="Edit proposed ${escapeHtml(targetLabel)}"`;
600
+ const control = renderTypedProposedControl(descriptor, valueText, commonAttrs);
601
+ return `
602
+ <div class="editrow">
603
+ ${control}
604
+ <span class="ehint">${escapeHtml(proposedEditorHint(descriptor))}</span>
600
605
  </div>
606
+ <span class="verr" data-testid="value-error" role="alert" hidden></span>
601
607
  `;
602
608
  }
609
+ /**
610
+ * Picks the concrete input control for the proposed-value editor from the
611
+ * neutral value-type descriptor. All variants carry the SAME
612
+ * `data-testid="edit-proposed-value"` and `.value` semantics so the mount
613
+ * handler reads a reviewer's edit uniformly (both `<input>` and `<select>`
614
+ * expose `.value`). Falls back to a plain text input when there is no
615
+ * descriptor, an enum without a declared set, or a free-form type
616
+ * (string/array/object).
617
+ */
618
+ function renderTypedProposedControl(descriptor, valueText, commonAttrs) {
619
+ switch (descriptor?.type) {
620
+ case "enum": {
621
+ const allowed = descriptor.enumValues ?? [];
622
+ if (allowed.length === 0)
623
+ break;
624
+ // Keep an out-of-set current value selectable rather than silently
625
+ // rewriting it — the reviewer sees exactly what was proposed.
626
+ const leadingOption = allowed.includes(valueText)
627
+ ? ""
628
+ : `<option value="${escapeHtml(valueText)}" selected>${escapeHtml(valueText === "" ? "(unset)" : valueText)}</option>`;
629
+ const options = allowed
630
+ .map((opt) => `<option value="${escapeHtml(opt)}"${opt === valueText ? " selected" : ""}>${escapeHtml(opt)}</option>`)
631
+ .join("");
632
+ return `<select${commonAttrs}>${leadingOption}${options}</select>`;
633
+ }
634
+ case "boolean": {
635
+ const options = ["true", "false"]
636
+ .map((opt) => `<option value="${opt}"${opt === valueText ? " selected" : ""}>${opt}</option>`)
637
+ .join("");
638
+ return `<select${commonAttrs}>${options}</select>`;
639
+ }
640
+ case "date":
641
+ return `<input type="date"${commonAttrs} value="${escapeHtml(toDateInputValue(valueText))}">`;
642
+ case "number":
643
+ return `<input type="number" inputmode="decimal"${commonAttrs} value="${escapeHtml(valueText)}">`;
644
+ default:
645
+ break;
646
+ }
647
+ return `<input type="text"${commonAttrs} value="${escapeHtml(valueText)}">`;
648
+ }
649
+ /** The trailing editor hint, keyed to the declared value type. */
650
+ function proposedEditorHint(descriptor) {
651
+ switch (descriptor?.type) {
652
+ case "enum":
653
+ return "choose one";
654
+ case "boolean":
655
+ return "true / false";
656
+ case "date":
657
+ return "date";
658
+ case "number":
659
+ return "number";
660
+ default:
661
+ return "editable";
662
+ }
663
+ }
664
+ /** Normalizes an arbitrary value string to the `YYYY-MM-DD` an `<input type="date">` accepts. */
665
+ function toDateInputValue(text) {
666
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text))
667
+ return text;
668
+ const parsed = Date.parse(text);
669
+ return Number.isNaN(parsed) ? "" : new Date(parsed).toISOString().slice(0, 10);
670
+ }
671
+ /**
672
+ * Validates a reviewer's raw (string) edit of a proposed value against the
673
+ * field's neutral {@link ReviewValueDescriptor}, returning a human-readable
674
+ * error message when the value violates the declared type/enum constraint, or
675
+ * `undefined` when it is acceptable — including when there is no descriptor or
676
+ * the type carries no single-line constraint (string/array/object). This is a
677
+ * FORMAT check only: it never coerces or rewrites the value (the workbench
678
+ * stores the reviewer's string edit unchanged, as it did before typed editors).
679
+ */
680
+ export function validateProposedValue(descriptor, rawValue) {
681
+ if (!descriptor)
682
+ return undefined;
683
+ const value = rawValue.trim();
684
+ switch (descriptor.type) {
685
+ case "number":
686
+ if (value === "")
687
+ return "Enter a number.";
688
+ return Number.isFinite(Number(value)) ? undefined : `"${rawValue}" is not a number.`;
689
+ case "boolean":
690
+ if (value === "")
691
+ return "Choose true or false.";
692
+ return value === "true" || value === "false" ? undefined : `"${rawValue}" is not true or false.`;
693
+ case "date":
694
+ if (value === "")
695
+ return "Enter a date.";
696
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value))
697
+ ? undefined
698
+ : `"${rawValue}" is not a valid date (YYYY-MM-DD).`;
699
+ case "enum": {
700
+ const allowed = descriptor.enumValues ?? [];
701
+ if (allowed.length === 0)
702
+ return undefined; // nothing declared to enforce
703
+ return allowed.includes(value) ? undefined : `Choose one of: ${allowed.join(", ")}.`;
704
+ }
705
+ default:
706
+ return undefined;
707
+ }
708
+ }
603
709
  function renderProvenanceRow(item, proposed, presentationAdapter) {
604
710
  const excerpt = proposed.locator?.excerpt;
605
711
  if (!excerpt) {
@@ -862,6 +968,16 @@ function createReviewWorkbenchController(root, startState, options) {
862
968
  const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
863
969
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
864
970
  const note = itemName ? session.notesByItemName[itemName] : undefined;
971
+ // Carry the reviewer's inline edit in the event (accept-proposed only), so
972
+ // it survives replay and the server apply boundary derives the edited
973
+ // effectiveValue from snapshot + events alone. setDecision updates the
974
+ // session before this runs, so the edit is already in state.
975
+ const editedValue = itemName && decision === "accept-proposed" ? session.editedValuesByItemName?.[itemName] : undefined;
976
+ const defaultData = decision
977
+ ? (editedValue !== undefined
978
+ ? { workbenchDecision: decision, workbenchEditedValue: editedValue }
979
+ : { workbenchDecision: decision })
980
+ : undefined;
865
981
  events = [
866
982
  ...events,
867
983
  buildReviewSessionEvent(session, {
@@ -874,7 +990,7 @@ function createReviewWorkbenchController(root, startState, options) {
874
990
  candidateId: candidate?.id,
875
991
  status: definition?.status,
876
992
  rationale: note,
877
- data: dataOverride ?? (decision ? { workbenchDecision: decision } : undefined),
993
+ data: dataOverride ?? defaultData,
878
994
  }),
879
995
  ];
880
996
  };
@@ -976,6 +1092,25 @@ function bindFieldCardInteractions(root, controller) {
976
1092
  const itemName = button.dataset.itemName ?? "";
977
1093
  const field = button.closest("[data-testid='review-field']");
978
1094
  const input = field?.querySelector("[data-testid='edit-proposed-value']");
1095
+ const descriptor = controller
1096
+ .currentSession()
1097
+ .items.find((entry) => entry.metadata.name === itemName)?.spec.valueDescriptor;
1098
+ const error = validateProposedValue(descriptor, input?.value ?? "");
1099
+ const errorEl = field?.querySelector("[data-testid='value-error']");
1100
+ if (error) {
1101
+ // Block the decision and surface the reason inline; the transient DOM
1102
+ // message survives until the next interaction (no re-render on reject).
1103
+ if (errorEl) {
1104
+ errorEl.textContent = error;
1105
+ errorEl.hidden = false;
1106
+ }
1107
+ input?.focus?.();
1108
+ return;
1109
+ }
1110
+ if (errorEl) {
1111
+ errorEl.textContent = "";
1112
+ errorEl.hidden = true;
1113
+ }
979
1114
  controller.setDecision(itemName, "accept-proposed", input?.value);
980
1115
  controller.renderCurrentState();
981
1116
  });
@@ -404,7 +404,8 @@ h1, h2, h3, p {
404
404
  margin-top: 8px;
405
405
  }
406
406
 
407
- .editrow input {
407
+ .editrow input,
408
+ .editrow select {
408
409
  flex: 1;
409
410
  min-width: 0;
410
411
  font-size: 13.5px;
@@ -415,7 +416,8 @@ h1, h2, h3, p {
415
416
  padding: 6px 9px;
416
417
  }
417
418
 
418
- .editrow input:focus-visible {
419
+ .editrow input:focus-visible,
420
+ .editrow select:focus-visible {
419
421
  outline: 2px solid var(--k-brand);
420
422
  outline-offset: 1px;
421
423
  border-color: var(--k-brand);
@@ -427,6 +429,20 @@ h1, h2, h3, p {
427
429
  white-space: nowrap;
428
430
  }
429
431
 
432
+ /* Validation message for a typed proposed-value edit (hidden until the mount
433
+ handler blocks a "Use proposed" on an out-of-shape value). */
434
+ .verr {
435
+ display: block;
436
+ margin-top: 5px;
437
+ font-size: 11.5px;
438
+ font-weight: 600;
439
+ color: var(--k-negative);
440
+ }
441
+
442
+ .verr[hidden] {
443
+ display: none;
444
+ }
445
+
430
446
  /* confidence + provenance */
431
447
 
432
448
  .prov {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",