@kontourai/survey 1.11.0 → 1.13.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.
@@ -1,5 +1,5 @@
1
1
  import { canonicalJson } from "./canonical.js";
2
- import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
2
+ import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
3
3
  import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
4
4
  import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
5
5
  import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
@@ -41,6 +41,7 @@ export function buildReviewDecision(state) {
41
41
  rationale: state.note,
42
42
  authorizing: buildDecisionCardAuthorizing(state),
43
43
  projection,
44
+ editedValue: state.decision === "accept-proposed" ? state.editedValue : undefined,
44
45
  },
45
46
  status: {
46
47
  appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
@@ -248,6 +249,10 @@ export function buildReviewWorkbenchResultsFromSession(session) {
248
249
  return [];
249
250
  }
250
251
  const selectedCandidate = candidateForDecision(item, decision);
252
+ const editedValue = decision === "accept-proposed"
253
+ ? session.editedValuesByItemName?.[item.metadata.name]
254
+ : undefined;
255
+ const effectiveValue = effectiveValueForDecision(item, decision, editedValue);
251
256
  return [{
252
257
  reviewItemName: item.metadata.name,
253
258
  decision,
@@ -256,6 +261,9 @@ export function buildReviewWorkbenchResultsFromSession(session) {
256
261
  selectedCandidateRole: selectedCandidate.role,
257
262
  selectedValue: selectedCandidate.value,
258
263
  selectedDisplayValue: formatValue(selectedCandidate.value),
264
+ editedValue,
265
+ effectiveValue,
266
+ effectiveDisplayValue: formatValue(effectiveValue),
259
267
  unselectedCandidates: item.spec.candidates.filter((candidate) => candidate.id !== selectedCandidate.id),
260
268
  reviewDecision,
261
269
  status: reviewDecision.spec.status,
@@ -418,339 +426,407 @@ export function createPersistentReviewSessionEventStore(options) {
418
426
  },
419
427
  };
420
428
  }
421
- export function renderReviewWorkbenchHtml(state, events, options = {}) {
422
- if ("items" in state) {
423
- return renderReviewQueueSessionHtml(state, events, options.presentationAdapter);
429
+ export function renderReviewWorkbenchHtml(state, _events, options = {}) {
430
+ const session = queueSessionFromStartState(state);
431
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter);
432
+ }
433
+ /**
434
+ * The visual/decision state of a field card, derived from the reviewer's local
435
+ * decision for that ReviewItem, falling back to a producer-declared "resolved"
436
+ * candidate set (e.g. a pre-decided item seeded by the host) as already-kept.
437
+ */
438
+ function fieldCardState(item, decision) {
439
+ if (decision === "accept-proposed")
440
+ return "accepted";
441
+ if (decision === "keep-current")
442
+ return "kept";
443
+ if (decision === "reject-proposed")
444
+ return "rejected";
445
+ if (item.spec.candidateSetStatus === "resolved")
446
+ return "kept";
447
+ return "review";
448
+ }
449
+ function chipLabel(state) {
450
+ switch (state) {
451
+ case "accepted": return "Accepted";
452
+ case "kept": return "Kept current";
453
+ case "rejected": return "Kept — flagged wrong";
454
+ default: return "Needs review";
424
455
  }
456
+ }
457
+ function isEmptyValue(value) {
458
+ return value === undefined || value === null || value === "";
459
+ }
460
+ const ARROW_SVG = "<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>";
461
+ const WARNING_SVG = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" aria-hidden=\"true\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z\"/></svg>";
462
+ function renderReviewQueueSessionHtml(session, presentationAdapter) {
463
+ const totalCount = session.items.length;
464
+ const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
465
+ || item.spec.candidateSetStatus === "resolved").length;
466
+ const progressPct = totalCount === 0 ? 0 : Math.round((decidedCount / totalCount) * 100);
467
+ const headerItem = session.items[0];
468
+ const producerName = headerItem?.metadata.producer?.displayName;
469
+ const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
425
470
  return `
426
- <section class="workbench-shell" aria-label="Survey review workbench">
427
- ${renderWorkbenchHeader(state, options.presentationAdapter)}
428
- <div class="content-grid">
429
- ${renderReviewFocus(state, options.presentationAdapter)}
430
- ${renderDecisionColumn(state, options.presentationAdapter)}
431
- ${renderCandidateComparison(state, options.presentationAdapter)}
471
+ <section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
472
+ <header class="rhead">
473
+ <div class="top">
474
+ <div class="subj">
475
+ <p class="eyebrow">Proposed changes</p>
476
+ <h1>${escapeHtml(headerTitle)}</h1>
477
+ </div>
478
+ </div>
479
+ <div class="meta">
480
+ <span><b data-testid="fields-changed-count">${totalCount}</b> field${totalCount === 1 ? "" : "s"} to review</span>
481
+ </div>
482
+ <div class="progress">
483
+ <div class="bar"><i style="width:${progressPct}%"></i></div>
484
+ <span class="ptext"><b data-testid="decided-count">${decidedCount}</b> of <b>${totalCount}</b> decided</span>
485
+ <button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
486
+ </div>
487
+ </header>
488
+ <div class="fields" data-testid="review-fields">
489
+ ${session.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")}
432
490
  </div>
491
+ ${renderFooterTally(session)}
433
492
  </section>
434
493
  `;
435
494
  }
436
- function renderReviewQueueSessionHtml(session, events, presentationAdapter) {
437
- const state = currentReviewWorkbenchState(session);
438
- const sessionExport = buildReviewWorkbenchSessionExport(session, events);
495
+ function renderFieldCard(item, session, presentationAdapter) {
496
+ const decision = session.decisionsByItemName[item.metadata.name];
497
+ const state = fieldCardState(item, decision);
498
+ const decided = decision !== undefined;
499
+ const current = item.spec.candidates.find((candidate) => candidate.role === "current");
500
+ const proposed = item.spec.candidates.find((candidate) => candidate.role === "proposed");
501
+ const presentation = buildReviewItemPresentation(item, presentationAdapter);
502
+ const hasCurrentValue = current !== undefined && !isEmptyValue(current.value);
503
+ const kind = hasCurrentValue ? "Update" : "New";
504
+ const keepLabel = hasCurrentValue ? "Keep current" : "Leave unset";
505
+ const editedValue = session.editedValuesByItemName?.[item.metadata.name];
506
+ const proposedPresentationText = proposed
507
+ ? buildReviewCandidatePresentation(item, proposed, presentationAdapter, presentation.targetLabel).valueText
508
+ : "";
509
+ const effectiveProposedText = decision === "accept-proposed" && editedValue !== undefined
510
+ ? String(editedValue)
511
+ : proposedPresentationText;
512
+ const currentPresentationText = current && !isEmptyValue(current.value)
513
+ ? buildReviewCandidatePresentation(item, current, presentationAdapter, presentation.targetLabel).valueText
514
+ : undefined;
439
515
  return `
440
- <section class="workbench-shell" aria-label="Survey review workbench">
441
- ${renderWorkbenchHeader(state, presentationAdapter)}
442
- ${renderMobileQueueBar(session, state, presentationAdapter)}
443
- ${renderActiveReviewStrip(session, state, presentationAdapter)}
444
- <div class="queue-layout">
445
- <div class="queue-drawer-backdrop" data-testid="queue-drawer-backdrop" aria-hidden="true"></div>
446
- <aside class="queue-panel" aria-label="Review queue" role="dialog" aria-modal="false" id="queue-panel">
447
- <button class="queue-drawer-close" type="button" data-testid="queue-drawer-close" aria-label="Close queue panel">&#x2715;</button>
448
- ${renderQueueRows(session, presentationAdapter)}
449
- ${renderSessionSummary(session)}
450
- ${renderSessionAudit(session, sessionExport)}
451
- </aside>
452
- <div class="content-grid">
453
- ${renderReviewFocus(state, presentationAdapter)}
454
- ${renderDecisionColumn(state, presentationAdapter)}
455
- ${renderCandidateComparison(state, presentationAdapter)}
516
+ <section
517
+ class="field"
518
+ data-testid="review-field"
519
+ data-item-name="${escapeHtml(item.metadata.name)}"
520
+ data-field="${escapeHtml(item.spec.target)}"
521
+ data-state="${state}"
522
+ data-decided="${decided ? "1" : "0"}"
523
+ ${decision ? `data-decision="${escapeHtml(decision)}"` : ""}
524
+ >
525
+ <div class="stripe"></div>
526
+ <div class="fbody">
527
+ <div class="frow1">
528
+ <span class="fname">${escapeHtml(presentation.targetLabel)}</span>
529
+ <span class="fkind">${kind}</span>
530
+ <span class="chip ${state} push" data-testid="field-chip">${chipLabel(state)}</span>
531
+ </div>
532
+ ${proposed
533
+ ? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
534
+ : "<p class=\"field-value\">No proposed value is available for this field.</p>"}
535
+ ${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
536
+ <div class="decide">
537
+ <button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>
538
+ <button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
539
+ <label class="wrong">
540
+ <input type="checkbox" class="wrongbox" data-testid="wrong-toggle" data-item-name="${escapeHtml(item.metadata.name)}">
541
+ Suggestion was wrong
542
+ </label>
543
+ </div>
544
+ <div class="decided">
545
+ <span class="chip ${state}" data-testid="decided-chip">${chipLabel(state)}</span>
546
+ <button class="undo" type="button" data-testid="undo-decision" data-item-name="${escapeHtml(item.metadata.name)}">Change</button>
456
547
  </div>
548
+ ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
457
549
  </div>
458
550
  </section>
459
551
  `;
460
552
  }
461
- function renderMobileQueueBar(session, state, presentationAdapter) {
462
- const activeIndex = session.items.findIndex((item) => item.metadata.name === session.activeItemName);
463
- const position = activeIndex >= 0 ? activeIndex + 1 : 1;
464
- const resolved = Object.keys(session.decisionsByItemName).length;
465
- const presentation = buildReviewItemPresentation(state.item, presentationAdapter);
553
+ /**
554
+ * Renders the Current → Proposed diff row. The proposed side is editable
555
+ * (via {@link renderProposedValueEditor}) until the field has a decision.
556
+ */
557
+ function renderDiffRow(item, current, proposed, targetLabel, decided, effectiveProposedText, currentPresentationText) {
558
+ const isEmpty = currentPresentationText === undefined;
559
+ const currentText = isEmpty ? "Not set" : currentPresentationText;
466
560
  return `
467
- <div class="mobile-queue-bar" data-testid="mobile-queue-bar" aria-label="Queue progress">
468
- <div class="mobile-queue-progress">
469
- <span class="field-label">Queue · ${resolved} of ${session.items.length} resolved</span>
470
- <span class="mobile-queue-item-label">${escapeHtml(presentation.targetLabel)}</span>
561
+ <div class="diff">
562
+ <div class="val current${isEmpty ? " empty" : ""}">
563
+ <div class="vlbl">Current</div>
564
+ <div class="vtext" data-testid="current-value">${escapeHtml(currentText)}</div>
471
565
  </div>
472
- <button
473
- class="mobile-queue-open"
474
- type="button"
475
- data-testid="mobile-queue-open"
476
- aria-controls="queue-panel"
477
- aria-expanded="false"
478
- aria-label="Open review queue"
479
- >Queue</button>
480
- </div>
481
- `;
482
- }
483
- function renderSessionAudit(session, sessionExport) {
484
- const lastEvents = sessionExport.events.slice(-6);
485
- const replayed = replayReviewSessionEvents({
486
- ...session,
487
- activeItemName: session.items[0]?.metadata.name ?? session.activeItemName,
488
- notesByItemName: {},
489
- decisionsByItemName: {},
490
- }, sessionExport.events);
491
- const replayMatchesSession = sameStringRecord(replayed.decisionsByItemName, session.decisionsByItemName)
492
- && sameStringRecord(replayed.notesByItemName, session.notesByItemName);
493
- return `
494
- <section class="session-audit" data-testid="session-audit" aria-label="Review session audit trail">
495
- <div class="session-audit-head">
496
- <div>
497
- <span class="field-label">ReviewSession</span>
498
- <h2>${escapeHtml(sessionExport.session.metadata.name)}</h2>
499
- </div>
500
- <span class="state-label">${escapeHtml(replayMatchesSession ? "replay ok" : "replay drift")}</span>
566
+ <div class="arrow">${ARROW_SVG}</div>
567
+ <div class="val proposed">
568
+ <div class="vlbl">Proposed</div>
569
+ <div class="vtext" data-value data-testid="proposed-value">${escapeHtml(effectiveProposedText)}</div>
570
+ ${decided ? "" : renderProposedValueEditor(item, proposed, targetLabel)}
501
571
  </div>
502
- <dl class="summary-grid audit-grid">
503
- ${metaItem("Events", String(sessionExport.events.length))}
504
- ${metaItem("Decisions", String(sessionExport.decisions.length))}
505
- ${metaItem("Active item", sessionExport.session.status?.activeItemName ?? "none")}
506
- ${metaItem("Actor", sessionExport.session.spec.actor?.id ?? "unknown")}
507
- </dl>
508
- <ol class="session-event-list" data-testid="session-event-list">
509
- ${lastEvents.length === 0
510
- ? "<li><span class=\"field-label\">No events</span><span>No persisted review activity yet.</span></li>"
511
- : lastEvents.map(renderSessionEventRow).join("")}
512
- </ol>
513
- <details class="session-export" data-testid="session-export">
514
- <summary>Session export</summary>
515
- <pre>${escapeHtml(JSON.stringify(sessionExport, null, 2))}</pre>
516
- </details>
517
- </section>
572
+ </div>
518
573
  `;
519
574
  }
520
- function sameStringRecord(left, right) {
521
- const leftKeys = Object.keys(left).sort();
522
- const rightKeys = Object.keys(right).sort();
523
- return leftKeys.length === rightKeys.length
524
- && leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
525
- }
526
- function renderSessionEventRow(event) {
527
- const target = event.spec.reviewItemName ?? event.spec.activeItemName ?? event.spec.sessionName;
528
- const detail = [
529
- event.spec.status,
530
- event.spec.candidateId,
531
- event.spec.rationale,
532
- ].filter(Boolean).join(" - ");
575
+ /**
576
+ * Renders the inline editor for a proposed value.
577
+ *
578
+ * This is intentionally the ONLY place that knows how to edit a proposed value —
579
+ * a single seam so a later cross-repo change (spanning the upstream field-schema
580
+ * owner and downstream consumers) can swap in typed editors — a `<select>` built
581
+ * from an enum's allowed values, or date/number/boolean inputs — driven by an
582
+ * optional neutral value-type descriptor, without touching the rest of the
583
+ * field-card renderer. Survey deliberately has no value-type/enum system of its
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.
591
+ */
592
+ function renderProposedValueEditor(item, proposed, targetLabel) {
593
+ const descriptor = item.spec.valueDescriptor;
594
+ const valueText = formatValue(proposed.value);
595
+ const commonAttrs = `
596
+ class="proposed-value-input"
597
+ data-testid="edit-proposed-value"
598
+ data-item-name="${escapeHtml(item.metadata.name)}"
599
+ aria-label="Edit proposed ${escapeHtml(targetLabel)}"`;
600
+ const control = renderTypedProposedControl(descriptor, valueText, commonAttrs);
533
601
  return `
534
- <li>
535
- <span class="event-sequence">${escapeHtml(String(event.spec.sequence).padStart(2, "0"))}</span>
536
- <span>
537
- <strong>${escapeHtml(event.spec.eventType)}</strong>
538
- <small>${escapeHtml(target)}${detail ? ` - ${escapeHtml(detail)}` : ""}</small>
539
- </span>
540
- </li>
602
+ <div class="editrow">
603
+ ${control}
604
+ <span class="ehint">${escapeHtml(proposedEditorHint(descriptor))}</span>
605
+ </div>
606
+ <span class="verr" data-testid="value-error" role="alert" hidden></span>
541
607
  `;
542
608
  }
543
- function renderQueueRows(session, presentationAdapter) {
544
- return `
545
- <section class="queue-list" data-testid="review-queue">
546
- <div class="queue-head">
547
- <h2>Review queue</h2>
548
- <button class="next-button" type="button" data-testid="next-unresolved">Next unresolved</button>
549
- </div>
550
- ${session.items.map((item) => renderQueueRow(item, session, presentationAdapter)).join("")}
551
- </section>
552
- `;
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
+ }
553
663
  }
554
- function renderQueueRow(item, session, presentationAdapter) {
555
- const status = deriveQueueRowStatus(item, session);
556
- const isActive = item.metadata.name === session.activeItemName;
557
- const presentation = buildReviewItemPresentation(item, presentationAdapter);
558
- return `
559
- <button class="queue-row${isActive ? " is-active" : ""}" type="button" data-item-name="${escapeHtml(item.metadata.name)}" data-testid="queue-row" data-queue-status="${status}">
560
- <span class="queue-row-main">
561
- <span class="queue-row-title">${escapeHtml(presentation.targetLabel)}</span>
562
- <span class="queue-row-target">${escapeHtml(item.metadata.name)}</span>
563
- </span>
564
- <span class="state-label">${escapeHtml(status)}</span>
565
- </button>
566
- `;
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);
567
670
  }
568
- function renderSessionSummary(session) {
569
- const summary = reviewSessionSummary(session);
570
- return `
571
- <section class="session-summary" data-testid="session-summary">
572
- <h2>Session summary</h2>
573
- <dl class="summary-grid">
574
- ${metaItem("Accepted", String(summary.accepted))}
575
- ${metaItem("Kept current", String(summary.keptCurrent))}
576
- ${metaItem("Rejected", String(summary.rejected))}
577
- ${metaItem("Escalated", String(summary.escalated))}
578
- ${metaItem("Unresolved", String(summary.unresolved))}
579
- </dl>
580
- </section>
581
- `;
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
+ }
582
708
  }
583
- function renderActiveReviewStrip(session, state, presentationAdapter) {
584
- const activeIndex = session.items.findIndex((item) => item.metadata.name === session.activeItemName);
585
- const position = activeIndex >= 0 ? activeIndex + 1 : 1;
586
- const status = deriveQueueRowStatus(state.item, session);
587
- const presentation = buildReviewItemPresentation(state.item, presentationAdapter);
588
- return `
589
- <section class="active-review-strip" data-testid="active-review-strip" aria-label="Active review item">
590
- <div class="active-review-copy">
591
- <span class="field-label">Review ${position} of ${session.items.length}</span>
592
- <strong>${escapeHtml(presentation.targetLabel)}</strong>
593
- <span>${escapeHtml(state.item.metadata.name)}</span>
594
- </div>
595
- <div class="active-review-actions">
596
- <span class="state-label">${escapeHtml(status)}</span>
597
- <button class="next-button" type="button" data-testid="active-next-unresolved">Next unresolved</button>
709
+ function renderProvenanceRow(item, proposed, presentationAdapter) {
710
+ const excerpt = proposed.locator?.excerpt;
711
+ if (!excerpt) {
712
+ return `
713
+ <div class="prov">
714
+ <div class="noprov" data-testid="no-source-flag">
715
+ ${WARNING_SVG}
716
+ <span class="tag">No source</span>
717
+ <span>This value has no supporting excerpt — verify before accepting.</span>
718
+ </div>
598
719
  </div>
599
- </section>
600
- `;
601
- }
602
- function renderWorkbenchHeader(state, presentationAdapter) {
603
- const presentation = buildReviewItemPresentation(state.item, presentationAdapter);
720
+ `;
721
+ }
722
+ const confidence = proposed.extraction.confidence ?? proposed.confidence;
723
+ const presentation = buildReviewCandidatePresentation(item, proposed, presentationAdapter);
724
+ const sourceLinkHtml = presentation.sourceLink
725
+ ? `<a href="${escapeHtml(presentation.sourceLink.href)}">${escapeHtml(presentation.sourceLink.label ?? presentation.sourceText)}</a>`
726
+ : escapeHtml(presentation.sourceText);
604
727
  return `
605
- <header class="topbar">
606
- <div>
607
- <p class="eyebrow">${escapeHtml(String(state.item.metadata.producer?.displayName ?? "Survey"))}</p>
608
- <h1>Review candidate update</h1>
609
- <p class="review-target-label" data-testid="review-target-label"><strong>${escapeHtml(presentation.targetLabel)}</strong></p>
728
+ <div class="prov">
729
+ ${confidence === undefined ? "" : renderConfidenceMeter(confidence)}
730
+ <div class="excerpt" data-testid="proposed-excerpt">
731
+ <q>${escapeHtml(excerpt)}</q>
732
+ <span class="from">from ${sourceLinkHtml}</span>
610
733
  </div>
611
- <dl class="meta-grid">
612
- ${metaItem("Item", state.item.metadata.name)}
613
- ${metaItem("Status", presentation.statusLabel)}
614
- ${metaItem("Selected", state.item.spec.selectedCandidateId ?? "none")}
615
- ${metaItem("Candidate count", String(state.item.status?.observedCandidateCount ?? state.item.spec.candidates.length))}
616
- </dl>
617
- </header>
618
- `;
619
- }
620
- function renderReviewMain(state) {
621
- return `
622
- <div class="review-main">
623
- ${renderReviewFocus(state)}
624
- ${renderCandidateComparison(state)}
625
734
  </div>
626
735
  `;
627
736
  }
628
- function renderReviewFocus(state, presentationAdapter) {
629
- const current = candidateByRole(state.item, "current");
630
- const proposed = candidateByRole(state.item, "proposed");
631
- const proposedConfidence = proposed.extraction.confidence ?? proposed.confidence;
632
- const currentConfidence = current.extraction.confidence ?? current.confidence;
633
- const presentation = buildReviewItemPresentation(state.item, presentationAdapter);
737
+ function renderConfidenceMeter(confidence) {
738
+ const clamped = Math.max(0, Math.min(1, confidence));
739
+ const pct = Math.round(clamped * 100);
740
+ const isMid = clamped < 0.85;
634
741
  return `
635
- <section class="review-focus" data-testid="review-focus" aria-label="Active review focus">
636
- <div class="focus-head">
637
- <span class="field-label">Active review</span>
638
- <span class="state-label">${escapeHtml(presentation.statusLabel)}</span>
639
- </div>
640
- <div class="focus-values">
641
- ${focusValue(state.item, current, currentConfidence, "current", presentationAdapter, presentation.targetLabel)}
642
- ${focusValue(state.item, proposed, proposedConfidence, "proposed", presentationAdapter, presentation.targetLabel)}
643
- </div>
644
- <dl class="focus-evidence">
645
- ${fieldItem("Target", presentation.targetLabel)}
646
- ${fieldItem("Proposed Source Reference", proposed.source.sourceRef)}
647
- ${fieldItemClamped("Proposed excerpt", proposed.locator?.excerpt ?? "none", "excerpt")}
648
- </dl>
649
- </section>
650
- `;
651
- }
652
- function focusValue(item, candidate, confidence, tone, presentationAdapter, targetLabel) {
653
- const presentation = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel);
654
- return `
655
- <div class="focus-value is-${tone}">
656
- <span class="field-label">${escapeHtml(presentation.roleLabel)}</span>
657
- <strong>${escapeHtml(presentation.valueText)}</strong>
658
- <span>${escapeHtml(confidence === undefined ? "confidence unknown" : formatConfidence(confidence))}</span>
742
+ <div class="conf" data-testid="confidence-meter">
743
+ <span>Confidence</span>
744
+ <span class="meter${isMid ? " mid" : ""}"><i style="width:${pct}%"></i></span>
745
+ <span class="pct">${clamped.toFixed(2)}</span>
659
746
  </div>
660
747
  `;
661
748
  }
662
- function renderCandidateComparison(state, presentationAdapter) {
663
- const current = candidateByRole(state.item, "current");
664
- const proposed = candidateByRole(state.item, "proposed");
665
- return `
666
- <section class="candidate-grid" aria-label="Candidate comparison">
667
- ${renderCandidateCard(current, state, presentationAdapter)}
668
- ${renderCandidateCard(proposed, state, presentationAdapter)}
669
- </section>
670
- `;
671
- }
672
- function renderDecisionColumn(state, presentationAdapter) {
673
- return `
674
- <aside class="decision-column" aria-label="Review decision">
675
- ${renderDecisionControls(state)}
676
- ${renderSurfacePreview(state, presentationAdapter)}
677
- ${renderDecisionPayload(state)}
678
- </aside>
679
- `;
680
- }
681
- function renderDecisionControls(state) {
682
- const activeDefinition = state.decision ? workbenchDecisionDefinitions[state.decision] : undefined;
749
+ /**
750
+ * The single collapsed power-user/audit surface per field card: reviewer note,
751
+ * decision effect, candidate/claim/source ids, and (once a decision has been made)
752
+ * the Surface projection preview and the raw ReviewDecision payload. Everything
753
+ * here is secondary detail tucked behind one `<details>` toggle, kept off the
754
+ * primary review surface.
755
+ */
756
+ function renderAuditDetails(item, current, proposed, session, presentationAdapter) {
757
+ const decision = session.decisionsByItemName[item.metadata.name];
758
+ const note = session.notesByItemName[item.metadata.name] ?? "";
759
+ const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
760
+ const state = {
761
+ item,
762
+ note,
763
+ decision,
764
+ editedValue: session.editedValuesByItemName?.[item.metadata.name],
765
+ reviewedAt: session.reviewedAt,
766
+ actorId: session.actorId,
767
+ };
768
+ const reviewDecisionPayload = buildReviewDecision(state);
769
+ const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
683
770
  return `
684
- <section class="decision-row">
685
- <div class="decision-buttons">
686
- ${renderDecisionButtons(state)}
687
- </div>
688
- <div class="effect" data-testid="decision-effect">
689
- <span class="field-label">Decision effect</span>
690
- <span class="field-value">${escapeHtml(activeDefinition?.effect ?? "No decision selected.")}</span>
771
+ <details class="audit-details" data-testid="audit-details">
772
+ <summary>Audit details</summary>
773
+ <div class="audit-body">
774
+ <label class="note-field">
775
+ <span class="field-label">Reviewer note</span>
776
+ <textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
777
+ </label>
778
+ ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
779
+ ${renderProducerFeedbackTags(item)}
780
+ <dl class="field-stack compact">
781
+ ${current ? fieldItem("Current candidate ID", current.id) : ""}
782
+ ${proposed ? fieldItem("Proposed candidate ID", proposed.id) : ""}
783
+ ${proposed ? fieldItem("Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
784
+ ${proposed ? fieldItem("Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
785
+ ${proposed?.locator ? fieldItem("Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
786
+ ${proposed?.extraction.model ? fieldItem("Model", proposed.extraction.model) : ""}
787
+ ${proposed ? fieldItem("Extractor", proposed.extraction.extractor ?? "unknown") : ""}
788
+ ${proposed ? fieldItem("Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
789
+ </dl>
790
+ ${preview
791
+ ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
792
+ : "<p class=\"preview-disclaimer\">No decision recorded yet — pick an option above to preview the saved record.</p>"}
793
+ ${reviewDecisionPayload
794
+ ? `<details class="reference-details">
795
+ <summary>Saved record (JSON)</summary>
796
+ <pre data-testid="decision-payload">${escapeHtml(JSON.stringify(reviewDecisionPayload, null, 2))}</pre>
797
+ </details>`
798
+ : ""}
691
799
  </div>
692
- <label class="field">
693
- <span class="field-label">Reviewer note</span>
694
- <textarea id="reviewer-note" data-testid="reviewer-note">${escapeHtml(state.note)}</textarea>
695
- </label>
696
- ${renderFeedbackTags(state)}
697
- </section>
800
+ </details>
698
801
  `;
699
802
  }
700
- function renderFeedbackTags(state) {
701
- const tags = producerFeedbackTags(state.item);
803
+ function renderProducerFeedbackTags(item) {
804
+ const tags = item.spec.producerPolicy?.feedbackTags;
805
+ if (!Array.isArray(tags) || tags.length === 0) {
806
+ return "";
807
+ }
702
808
  return `
703
- <div class="feedback-tags" data-testid="producer-feedback-tags">
809
+ <div class="note-field">
704
810
  <span class="field-label">Producer feedback tags</span>
705
- <div class="tag-row">
706
- ${tags.length === 0 ? "<span class=\"tag is-empty\">none</span>" : tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("")}
707
- </div>
811
+ <p class="field-value">${tags.map((tag) => escapeHtml(tag)).join(", ")}</p>
708
812
  </div>
709
813
  `;
710
814
  }
711
- function renderDecisionButtons(state) {
712
- return Object.entries(workbenchDecisionDefinitions).map(([key, definition]) => `
713
- <button class="decision-button${state.decision === key ? " is-active" : ""}" type="button" data-decision="${key}">
714
- ${escapeHtml(definition.label)}
715
- </button>
716
- `).join("");
717
- }
718
- function renderDecisionPayload(state) {
719
- const decision = buildReviewDecision(state);
720
- return `
721
- <details class="payload-panel">
722
- <summary class="field-label">ReviewDecision payload</summary>
723
- <pre data-testid="decision-payload">${escapeHtml(JSON.stringify(decision ?? null, null, 2))}</pre>
724
- </details>
725
- `;
726
- }
727
- function renderSurfacePreview(state, presentationAdapter) {
728
- const preview = buildSurfaceProjectionPreview(state.item, buildReviewDecision(state), presentationAdapter);
729
- return preview ? renderPopulatedSurfacePreview(preview) : renderPendingSurfacePreview();
730
- }
731
- function renderPendingSurfacePreview() {
732
- return `
733
- <details class="surface-preview" data-testid="surface-preview" aria-label="Surface preview">
734
- <summary class="surface-summary">
735
- <span class="surface-summary-label">Surface preview</span>
736
- <span class="state-label">pending</span>
737
- </summary>
738
- <p class="preview-disclaimer">Choose a review decision to preview the Raw Source, Source Reference, and review posture that would project toward Surface.</p>
739
- </details>
740
- `;
741
- }
742
- function renderPopulatedSurfacePreview(preview) {
815
+ function renderFooterTally(session) {
816
+ const counts = { review: 0, accepted: 0, kept: 0, rejected: 0 };
817
+ for (const item of session.items) {
818
+ const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
819
+ counts[state] += 1;
820
+ }
743
821
  return `
744
- <details class="surface-preview" data-testid="surface-preview" aria-label="Surface preview">
745
- <summary class="surface-summary">
746
- <span class="surface-summary-label">Surface preview</span>
747
- <span class="state-label">local preview</span>
748
- </summary>
749
- <div class="preview-section-grid">
750
- ${renderSurfacePreviewSections(preview)}
822
+ <footer class="foot" data-testid="review-tally">
823
+ <div class="tally">
824
+ <span>Accepted <b data-testid="tally-accepted">${counts.accepted}</b></span>
825
+ <span>Kept <b data-testid="tally-kept">${counts.kept}</b></span>
826
+ <span>Flagged <b data-testid="tally-rejected">${counts.rejected}</b></span>
827
+ <span>Remaining <b data-testid="tally-review">${counts.review}</b></span>
751
828
  </div>
752
- <p class="preview-disclaimer preview-disclaimer-footer" data-testid="surface-preview-disclaimer">${escapeHtml(preview.postureDisclaimer)}</p>
753
- </details>
829
+ </footer>
754
830
  `;
755
831
  }
756
832
  function renderSurfacePreviewSections(preview) {
@@ -762,15 +838,6 @@ function renderSurfacePreviewSections(preview) {
762
838
  renderAuthorityTrace(preview),
763
839
  ].join("");
764
840
  }
765
- function renderCanonicalClaim(preview) {
766
- return renderPreviewSection("Selected claim", "surface-canonical-claim", [
767
- ["Value", preview.canonicalClaim.value],
768
- ["Review status", preview.canonicalClaim.status],
769
- ], undefined, [
770
- ["Candidate ID", preview.canonicalClaim.candidateId],
771
- ["Claim ID", preview.canonicalClaim.claimId],
772
- ]);
773
- }
774
841
  function renderReviewEvent(preview) {
775
842
  return `
776
843
  <section class="preview-section" data-testid="surface-review-event">
@@ -825,7 +892,7 @@ function renderCandidateHistory(preview) {
825
892
  return `
826
893
  <section class="preview-section" data-testid="surface-candidate-history">
827
894
  <h3>${escapeHtml("Unselected candidate history")}</h3>
828
- <div class="history-expander${overflowCandidates.length > 0 ? "" : ""}" data-history-expander>
895
+ <div class="history-expander" data-history-expander>
829
896
  <dl class="field-stack compact">
830
897
  ${renderHistoryRows(visibleCandidates)}
831
898
  ${overflowHtml}
@@ -840,9 +907,7 @@ function renderCandidateHistory(preview) {
840
907
  `;
841
908
  }
842
909
  function renderSourceEvidence(preview) {
843
- // Excerpt uses a 3-line clamp wrapper with a toggle button.
844
910
  const clampedExcerptHtml = fieldItemClamped("Excerpt", preview.sourceEvidence.excerpt, "excerpt");
845
- // Build Raw Source section with clamped excerpt inline.
846
911
  return `
847
912
  <section class="preview-section" data-testid="surface-source-evidence">
848
913
  <h3>${escapeHtml("Raw Source")}</h3>
@@ -897,11 +962,12 @@ function createReviewWorkbenchController(root, startState, options) {
897
962
  const persistEvents = () => {
898
963
  eventStore?.save(session, events);
899
964
  };
900
- const appendEvent = (eventType) => {
901
- const state = currentReviewWorkbenchState(session);
902
- const decision = state.decision;
903
- const candidate = decision ? candidateForDecision(state.item, decision) : undefined;
965
+ const appendEvent = (eventType, itemName, dataOverride) => {
966
+ const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
967
+ const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
968
+ const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
904
969
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
970
+ const note = itemName ? session.notesByItemName[itemName] : undefined;
905
971
  events = [
906
972
  ...events,
907
973
  buildReviewSessionEvent(session, {
@@ -909,70 +975,74 @@ function createReviewWorkbenchController(root, startState, options) {
909
975
  sequence: events.length + 1,
910
976
  eventType,
911
977
  occurredAt: session.reviewedAt,
912
- reviewItemName: state.item.metadata.name,
913
- activeItemName: eventType === "item-selected" ? session.activeItemName : undefined,
914
- reviewDecisionName: decision ? `${state.item.metadata.name}-${decision}` : undefined,
978
+ reviewItemName: itemName,
979
+ reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
915
980
  candidateId: candidate?.id,
916
981
  status: definition?.status,
917
- rationale: state.note,
918
- data: decision ? { workbenchDecision: decision } : undefined,
982
+ rationale: note,
983
+ data: dataOverride ?? (decision ? { workbenchDecision: decision } : undefined),
919
984
  }),
920
985
  ];
921
986
  };
922
987
  const applySessionUpdate = (update) => {
923
988
  session = update(session);
924
989
  };
925
- const selectDecision = (decision) => {
990
+ const setDecision = (itemName, decision, rawEditedValue) => {
991
+ applySessionUpdate((current) => {
992
+ const item = current.items.find((entry) => entry.metadata.name === itemName);
993
+ const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
994
+ const originalText = proposed ? formatValue(proposed.value) : undefined;
995
+ const nextEditedValuesByItemName = { ...current.editedValuesByItemName };
996
+ if (decision === "accept-proposed" && rawEditedValue !== undefined && rawEditedValue !== originalText) {
997
+ nextEditedValuesByItemName[itemName] = rawEditedValue;
998
+ }
999
+ else {
1000
+ delete nextEditedValuesByItemName[itemName];
1001
+ }
1002
+ return {
1003
+ ...current,
1004
+ activeItemName: itemName,
1005
+ decisionsByItemName: { ...current.decisionsByItemName, [itemName]: decision },
1006
+ editedValuesByItemName: nextEditedValuesByItemName,
1007
+ };
1008
+ });
1009
+ appendEvent("decision-changed", itemName);
1010
+ persistEvents();
1011
+ };
1012
+ const clearDecision = (itemName) => {
1013
+ applySessionUpdate((current) => {
1014
+ const remainingDecisions = { ...current.decisionsByItemName };
1015
+ delete remainingDecisions[itemName];
1016
+ const remainingEdits = { ...current.editedValuesByItemName };
1017
+ delete remainingEdits[itemName];
1018
+ return {
1019
+ ...current,
1020
+ activeItemName: itemName,
1021
+ decisionsByItemName: remainingDecisions,
1022
+ editedValuesByItemName: remainingEdits,
1023
+ };
1024
+ });
1025
+ appendEvent("decision-changed", itemName, { workbenchDecision: null });
1026
+ persistEvents();
1027
+ };
1028
+ const updateReviewerNote = (itemName, note) => {
926
1029
  applySessionUpdate((current) => ({
927
1030
  ...current,
928
- decisionsByItemName: {
929
- ...current.decisionsByItemName,
930
- [currentReviewItem(current).metadata.name]: decision,
931
- },
1031
+ notesByItemName: { ...current.notesByItemName, [itemName]: note },
932
1032
  }));
933
- appendEvent("decision-changed");
1033
+ appendEvent("note-changed", itemName);
934
1034
  persistEvents();
935
1035
  };
936
1036
  const controller = {
937
- currentState: () => currentReviewWorkbenchState(session),
938
1037
  currentSession: () => session,
939
1038
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
940
- goToNextUnresolved: () => {
941
- applyNextUnresolvedSessionUpdate(applySessionUpdate, session);
942
- appendEvent("item-selected");
943
- persistEvents();
944
- },
945
1039
  renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter),
946
- selectDecision,
947
- selectQueueItem: (itemName) => {
948
- applySessionUpdate((current) => ({ ...current, activeItemName: itemName }));
949
- appendEvent("item-selected");
950
- persistEvents();
951
- },
952
- updateReviewerNote: (note) => {
953
- applyReviewerNoteSessionUpdate(applySessionUpdate, session, note);
954
- appendEvent("note-changed");
955
- persistEvents();
956
- },
1040
+ setDecision,
1041
+ clearDecision,
1042
+ updateReviewerNote,
957
1043
  };
958
1044
  return controller;
959
1045
  }
960
- function applyReviewerNoteSessionUpdate(applySessionUpdate, session, note) {
961
- const itemName = currentReviewItem(session).metadata.name;
962
- applySessionUpdate((current) => ({
963
- ...current,
964
- notesByItemName: {
965
- ...current.notesByItemName,
966
- [itemName]: note,
967
- },
968
- }));
969
- }
970
- function applyNextUnresolvedSessionUpdate(applySessionUpdate, session) {
971
- const next = nextUnresolvedItemName(session);
972
- if (next) {
973
- applySessionUpdate((current) => ({ ...current, activeItemName: next }));
974
- }
975
- }
976
1046
  function browserReviewSessionEventStore() {
977
1047
  if (typeof window === "undefined" || !window.localStorage) {
978
1048
  return undefined;
@@ -984,22 +1054,9 @@ function reviewSessionStorageKey(session, keyPrefix) {
984
1054
  return `${keyPrefix}:${itemNames}`;
985
1055
  }
986
1056
  function renderCurrentState(root, session, controller, presentationAdapter) {
987
- root.innerHTML = renderReviewWorkbenchHtml(session, controller.currentSessionExport().events, { presentationAdapter });
988
- bindReviewerNote(root, controller.updateReviewerNote, controller.currentState, controller.currentSession, controller.currentSessionExport, controller.renderCurrentState, presentationAdapter);
989
- bindDecisionButtons(root, (decision) => {
990
- controller.selectDecision(decision);
991
- controller.renderCurrentState();
992
- });
993
- bindQueueRows(root, (itemName) => {
994
- controller.selectQueueItem(itemName);
995
- controller.renderCurrentState();
996
- closeQueueDrawer(root);
997
- });
998
- bindNextUnresolved(root, () => {
999
- controller.goToNextUnresolved();
1000
- controller.renderCurrentState();
1001
- });
1002
- bindQueueDrawer(root);
1057
+ root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter });
1058
+ bindFieldCardInteractions(root, controller);
1059
+ bindApplyButton(root, controller);
1003
1060
  bindClampToggles(root);
1004
1061
  bindHistoryExpanders(root);
1005
1062
  }
@@ -1012,42 +1069,108 @@ function queueSessionFromStartState(startState) {
1012
1069
  activeItemName: startState.item.metadata.name,
1013
1070
  notesByItemName: startState.note ? { [startState.item.metadata.name]: startState.note } : {},
1014
1071
  decisionsByItemName: startState.decision ? { [startState.item.metadata.name]: startState.decision } : {},
1072
+ editedValuesByItemName: startState.editedValue !== undefined
1073
+ ? { [startState.item.metadata.name]: startState.editedValue }
1074
+ : {},
1015
1075
  reviewedAt: startState.reviewedAt,
1016
1076
  actorId: startState.actorId,
1017
1077
  };
1018
1078
  }
1019
- function bindReviewerNote(root, updateReviewerNote, currentState, currentSession, currentSessionExport, renderCurrentState, presentationAdapter) {
1020
- root.querySelector("[data-testid='reviewer-note']")?.addEventListener("input", (event) => {
1021
- updateReviewerNote(event.target.value);
1022
- refreshDecisionOutputs(root, currentState(), renderCurrentState, presentationAdapter);
1023
- refreshSessionAudit(root, currentSession(), currentSessionExport(), renderCurrentState);
1024
- });
1025
- }
1026
- function bindDecisionButtons(root, selectDecision) {
1027
- root.querySelectorAll("[data-decision]").forEach((button) => {
1079
+ function bindFieldCardInteractions(root, controller) {
1080
+ root.querySelectorAll("[data-testid='use-proposed']").forEach((button) => {
1028
1081
  button.addEventListener("click", () => {
1029
- selectDecision(button.dataset.decision);
1082
+ const itemName = button.dataset.itemName ?? "";
1083
+ const field = button.closest("[data-testid='review-field']");
1084
+ const input = field?.querySelector("[data-testid='edit-proposed-value']");
1085
+ const descriptor = controller
1086
+ .currentSession()
1087
+ .items.find((entry) => entry.metadata.name === itemName)?.spec.valueDescriptor;
1088
+ const error = validateProposedValue(descriptor, input?.value ?? "");
1089
+ const errorEl = field?.querySelector("[data-testid='value-error']");
1090
+ if (error) {
1091
+ // Block the decision and surface the reason inline; the transient DOM
1092
+ // message survives until the next interaction (no re-render on reject).
1093
+ if (errorEl) {
1094
+ errorEl.textContent = error;
1095
+ errorEl.hidden = false;
1096
+ }
1097
+ input?.focus?.();
1098
+ return;
1099
+ }
1100
+ if (errorEl) {
1101
+ errorEl.textContent = "";
1102
+ errorEl.hidden = true;
1103
+ }
1104
+ controller.setDecision(itemName, "accept-proposed", input?.value);
1105
+ controller.renderCurrentState();
1030
1106
  });
1031
1107
  });
1032
- }
1033
- function bindQueueRows(root, selectQueueItem) {
1034
- root.querySelectorAll("[data-item-name]").forEach((button) => {
1108
+ root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
1035
1109
  button.addEventListener("click", () => {
1036
- selectQueueItem(button.dataset.itemName ?? "");
1037
- scrollActiveReviewIntoView(root);
1110
+ const itemName = button.dataset.itemName ?? "";
1111
+ const field = button.closest("[data-testid='review-field']");
1112
+ const wrong = field?.querySelector("[data-testid='wrong-toggle']");
1113
+ const decision = wrong?.checked ? "reject-proposed" : "keep-current";
1114
+ controller.setDecision(itemName, decision);
1115
+ controller.renderCurrentState();
1038
1116
  });
1039
1117
  });
1040
- }
1041
- function bindNextUnresolved(root, goToNextUnresolved) {
1042
- root
1043
- .querySelectorAll("[data-testid='next-unresolved'], [data-testid='active-next-unresolved']")
1044
- .forEach((button) => {
1118
+ root.querySelectorAll("[data-testid='undo-decision']").forEach((button) => {
1045
1119
  button.addEventListener("click", () => {
1046
- goToNextUnresolved();
1047
- scrollActiveReviewIntoView(root);
1120
+ controller.clearDecision(button.dataset.itemName ?? "");
1121
+ controller.renderCurrentState();
1122
+ });
1123
+ });
1124
+ root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
1125
+ textarea.addEventListener("input", () => {
1126
+ const itemName = textarea.dataset.itemName ?? "";
1127
+ controller.updateReviewerNote(itemName, textarea.value);
1128
+ refreshAuditPayloadForItem(textarea, controller, itemName);
1048
1129
  });
1049
1130
  });
1050
1131
  }
1132
+ function bindApplyButton(root, controller) {
1133
+ const button = root.querySelector("[data-testid='apply-button']");
1134
+ button?.addEventListener("click", () => {
1135
+ if (typeof CustomEvent !== "function" || typeof root.dispatchEvent !== "function") {
1136
+ return;
1137
+ }
1138
+ root.dispatchEvent(new CustomEvent("survey:review-workbench-apply", {
1139
+ bubbles: true,
1140
+ composed: true,
1141
+ detail: {
1142
+ session: controller.currentSession(),
1143
+ sessionExport: controller.currentSessionExport(),
1144
+ },
1145
+ }));
1146
+ });
1147
+ }
1148
+ /**
1149
+ * After a reviewer-note keystroke, patch just that field's decision-payload `<pre>`
1150
+ * in place instead of a full re-render — a full re-render would close the audit
1151
+ * `<details>` and drop focus/caret position on every keystroke.
1152
+ */
1153
+ function refreshAuditPayloadForItem(textarea, controller, itemName) {
1154
+ const field = textarea.closest("[data-testid='review-field']");
1155
+ const payload = field?.querySelector("[data-testid='decision-payload']");
1156
+ if (!payload) {
1157
+ return;
1158
+ }
1159
+ const session = controller.currentSession();
1160
+ const item = session.items.find((entry) => entry.metadata.name === itemName);
1161
+ if (!item) {
1162
+ return;
1163
+ }
1164
+ const state = {
1165
+ item,
1166
+ note: textarea.value,
1167
+ decision: session.decisionsByItemName[itemName],
1168
+ editedValue: session.editedValuesByItemName?.[itemName],
1169
+ reviewedAt: session.reviewedAt,
1170
+ actorId: session.actorId,
1171
+ };
1172
+ payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
1173
+ }
1051
1174
  function bindClampToggles(root) {
1052
1175
  root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {
1053
1176
  button.addEventListener("click", () => {
@@ -1071,152 +1194,9 @@ function bindHistoryExpanders(root) {
1071
1194
  });
1072
1195
  });
1073
1196
  }
1074
- function bindQueueDrawer(root) {
1075
- const openButton = root.querySelector("[data-testid='mobile-queue-open']");
1076
- const closeButton = root.querySelector("[data-testid='queue-drawer-close']");
1077
- const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
1078
- const panel = root.querySelector("#queue-panel");
1079
- openButton?.addEventListener("click", () => openQueueDrawer(root));
1080
- closeButton?.addEventListener("click", () => closeQueueDrawer(root));
1081
- backdrop?.addEventListener("click", () => closeQueueDrawer(root));
1082
- if (typeof root.addEventListener === "function") {
1083
- root.addEventListener("keydown", (event) => {
1084
- if (event.key === "Escape" && panel?.classList.contains("is-open")) {
1085
- closeQueueDrawer(root);
1086
- }
1087
- });
1088
- }
1089
- }
1090
- function openQueueDrawer(root) {
1091
- const panel = root.querySelector("#queue-panel");
1092
- const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
1093
- const openButton = root.querySelector("[data-testid='mobile-queue-open']");
1094
- if (!panel) {
1095
- return;
1096
- }
1097
- panel.classList.add("is-open");
1098
- panel.setAttribute("aria-modal", "true");
1099
- backdrop?.classList.add("is-visible");
1100
- openButton?.setAttribute("aria-expanded", "true");
1101
- panel.querySelector("button, [tabindex]")?.focus();
1102
- }
1103
- function closeQueueDrawer(root) {
1104
- const panel = root.querySelector("#queue-panel");
1105
- const backdrop = root.querySelector("[data-testid='queue-drawer-backdrop']");
1106
- const openButton = root.querySelector("[data-testid='mobile-queue-open']");
1107
- if (!panel) {
1108
- return;
1109
- }
1110
- panel.classList.remove("is-open");
1111
- panel.setAttribute("aria-modal", "false");
1112
- backdrop?.classList.remove("is-visible");
1113
- openButton?.setAttribute("aria-expanded", "false");
1114
- openButton?.focus();
1115
- }
1116
- function scrollActiveReviewIntoView(root) {
1117
- if (typeof window === "undefined" || !window.matchMedia("(max-width: 980px)").matches) {
1118
- return;
1119
- }
1120
- root.querySelector("[data-testid='active-review-strip']")?.scrollIntoView({
1121
- block: "start",
1122
- behavior: "smooth",
1123
- });
1124
- }
1125
- function refreshDecisionOutputs(root, state, renderCurrentState, presentationAdapter) {
1126
- const payload = root.querySelector("[data-testid='decision-payload']");
1127
- if (payload) {
1128
- payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
1129
- }
1130
- const preview = root.querySelector("[data-testid='surface-preview']");
1131
- if (!preview) {
1132
- return;
1133
- }
1134
- if (typeof document === "undefined") {
1135
- renderCurrentState();
1136
- return;
1137
- }
1138
- const wrapper = document.createElement("div");
1139
- wrapper.innerHTML = renderSurfacePreview(state, presentationAdapter);
1140
- const newPreview = wrapper.firstElementChild;
1141
- preview.replaceWith(newPreview);
1142
- if (newPreview && typeof newPreview.querySelectorAll === "function") {
1143
- bindClampToggles(newPreview);
1144
- bindHistoryExpanders(newPreview);
1145
- }
1146
- }
1147
- function refreshSessionAudit(root, session, sessionExport, renderCurrentState) {
1148
- const audit = root.querySelector("[data-testid='session-audit']");
1149
- if (!audit) {
1150
- return;
1151
- }
1152
- if (typeof document === "undefined") {
1153
- renderCurrentState();
1154
- return;
1155
- }
1156
- const wrapper = document.createElement("div");
1157
- wrapper.innerHTML = renderSessionAudit(session, sessionExport);
1158
- audit.replaceWith(wrapper.firstElementChild);
1159
- }
1160
- function producerFeedbackTags(item) {
1161
- const tags = item.spec.producerPolicy?.feedbackTags;
1162
- return Array.isArray(tags) ? tags.map(formatValue) : [];
1163
- }
1164
- function renderCandidateCard(candidate, state, presentationAdapter) {
1165
- const selectedRole = selectedCandidateRole(state);
1166
- const candidateState = state.decision
1167
- ? candidate.role === selectedRole ? "selected" : "unselected"
1168
- : "pending";
1169
- const cssState = candidateState === "selected" ? " is-selected" : candidateState === "unselected" ? " is-unselected" : "";
1170
- const confidence = candidate.extraction.confidence ?? candidate.confidence;
1171
- const itemPresentation = buildReviewItemPresentation(state.item, presentationAdapter);
1172
- const presentation = buildReviewCandidatePresentation(state.item, candidate, presentationAdapter, itemPresentation.targetLabel);
1173
- return `
1174
- <article class="candidate-card${cssState}" data-testid="candidate-${escapeHtml(candidate.role ?? candidate.id)}" data-outcome="${candidateState}">
1175
- <div class="card-head">
1176
- <div>
1177
- <p class="eyebrow">${escapeHtml(candidate.role === "proposed" ? "incoming value" : candidate.role === "current" ? "existing value" : "candidate")}</p>
1178
- <h2 class="role">${escapeHtml(presentation.roleLabel)}</h2>
1179
- </div>
1180
- <span class="state-label">${escapeHtml(candidateState)}</span>
1181
- </div>
1182
- <div class="candidate-value">
1183
- <span class="field-label">${escapeHtml(presentation.valueLabel)}</span>
1184
- <p class="field-value">${escapeHtml(presentation.valueText)}</p>
1185
- </div>
1186
- <dl class="field-stack">
1187
- ${fieldItem(presentation.sourceLabel, presentation.sourceText)}
1188
- ${fieldItem("Locator", candidate.locator?.locator ?? candidate.locator?.scheme ?? "none")}
1189
- ${fieldItemClamped("Excerpt", candidate.locator?.excerpt ?? "none", "excerpt")}
1190
- ${fieldItem("Extraction confidence", confidence === undefined ? "unknown" : formatConfidence(confidence))}
1191
- ${fieldItem("Extractor", candidate.extraction.extractor ?? "unknown")}
1192
- </dl>
1193
- <details class="reference-details">
1194
- <summary>IDs and trace links</summary>
1195
- <dl class="field-stack compact">
1196
- ${presentation.traceRefs.map((ref) => fieldItem(ref.label, ref.value)).join("")}
1197
- </dl>
1198
- </details>
1199
- </article>
1200
- `;
1201
- }
1202
- function candidateByRole(item, role) {
1203
- const candidate = item.spec.candidates.find((entry) => entry.role === role);
1204
- if (!candidate) {
1205
- throw new Error(`ReviewItem ${item.metadata.name} has no ${role} candidate.`);
1206
- }
1207
- return candidate;
1208
- }
1209
- function metaItem(label, value) {
1210
- return `
1211
- <div class="meta-item">
1212
- <dt class="field-label">${escapeHtml(label)}</dt>
1213
- <dd class="meta-value">${escapeHtml(value)}</dd>
1214
- </div>
1215
- `;
1216
- }
1217
1197
  function fieldItemClamped(label, value, extraClass = "") {
1218
1198
  return `
1219
- <div class="field ${extraClass}">
1199
+ <div class="kv ${extraClass}">
1220
1200
  <dt class="field-label">${escapeHtml(label)}</dt>
1221
1201
  <div class="excerpt-clamp" data-clamp>
1222
1202
  <dd class="field-value">${escapeHtml(value)}</dd>
@@ -1227,15 +1207,12 @@ function fieldItemClamped(label, value, extraClass = "") {
1227
1207
  }
1228
1208
  function fieldItem(label, value, extraClass = "") {
1229
1209
  return `
1230
- <div class="field ${extraClass}">
1210
+ <div class="kv ${extraClass}">
1231
1211
  <dt class="field-label">${escapeHtml(label)}</dt>
1232
1212
  <dd class="field-value">${escapeHtml(value)}</dd>
1233
1213
  </div>
1234
1214
  `;
1235
1215
  }
1236
- function formatConfidence(value) {
1237
- return `${Math.round(value * 100)}% (${value.toFixed(2)})`;
1238
- }
1239
1216
  function escapeHtml(value) {
1240
1217
  return formatValue(value)
1241
1218
  .replaceAll("&", "&amp;")