@kontourai/survey 1.10.0 → 1.12.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,301 @@ 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
  }
425
- 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)}
432
- </div>
433
- </section>
434
- `;
435
456
  }
436
- function renderReviewQueueSessionHtml(session, events, presentationAdapter) {
437
- const state = currentReviewWorkbenchState(session);
438
- const sessionExport = buildReviewWorkbenchSessionExport(session, events);
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";
439
470
  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)}
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>
456
478
  </div>
457
- </div>
458
- </section>
459
- `;
460
- }
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);
466
- 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>
471
- </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>
479
+ <div class="meta">
480
+ <span><b data-testid="fields-changed-count">${totalCount}</b> field${totalCount === 1 ? "" : "s"} to review</span>
499
481
  </div>
500
- <span class="state-label">${escapeHtml(replayMatchesSession ? "replay ok" : "replay drift")}</span>
501
- </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>
518
- `;
519
- }
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(" - ");
533
- 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>
541
- `;
542
- }
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>
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("")}
549
490
  </div>
550
- ${session.items.map((item) => renderQueueRow(item, session, presentationAdapter)).join("")}
491
+ ${renderFooterTally(session)}
551
492
  </section>
552
493
  `;
553
494
  }
554
- function renderQueueRow(item, session, presentationAdapter) {
555
- const status = deriveQueueRowStatus(item, session);
556
- const isActive = item.metadata.name === session.activeItemName;
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");
557
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;
558
515
  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
- `;
567
- }
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
- `;
582
- }
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>
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>
547
+ </div>
548
+ ${renderAuditDetails(item, current, proposed, session, presentationAdapter)}
598
549
  </div>
599
550
  </section>
600
551
  `;
601
552
  }
602
- function renderWorkbenchHeader(state, presentationAdapter) {
603
- 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;
604
560
  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>
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>
610
565
  </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>
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)}
571
+ </div>
572
+ </div>
618
573
  `;
619
574
  }
620
- function renderReviewMain(state) {
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), so today this always
585
+ * renders a plain text input, matching the approved mockup.
586
+ */
587
+ function renderProposedValueEditor(item, proposed, targetLabel) {
588
+ const valueText = formatValue(proposed.value);
621
589
  return `
622
- <div class="review-main">
623
- ${renderReviewFocus(state)}
624
- ${renderCandidateComparison(state)}
590
+ <div class="editrow">
591
+ <input
592
+ type="text"
593
+ class="proposed-value-input"
594
+ data-testid="edit-proposed-value"
595
+ 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>
625
600
  </div>
626
601
  `;
627
602
  }
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);
634
- 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)}
603
+ function renderProvenanceRow(item, proposed, presentationAdapter) {
604
+ const excerpt = proposed.locator?.excerpt;
605
+ if (!excerpt) {
606
+ return `
607
+ <div class="prov">
608
+ <div class="noprov" data-testid="no-source-flag">
609
+ ${WARNING_SVG}
610
+ <span class="tag">No source</span>
611
+ <span>This value has no supporting excerpt — verify before accepting.</span>
612
+ </div>
643
613
  </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);
614
+ `;
615
+ }
616
+ const confidence = proposed.extraction.confidence ?? proposed.confidence;
617
+ const presentation = buildReviewCandidatePresentation(item, proposed, presentationAdapter);
618
+ const sourceLinkHtml = presentation.sourceLink
619
+ ? `<a href="${escapeHtml(presentation.sourceLink.href)}">${escapeHtml(presentation.sourceLink.label ?? presentation.sourceText)}</a>`
620
+ : escapeHtml(presentation.sourceText);
654
621
  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>
622
+ <div class="prov">
623
+ ${confidence === undefined ? "" : renderConfidenceMeter(confidence)}
624
+ <div class="excerpt" data-testid="proposed-excerpt">
625
+ <q>${escapeHtml(excerpt)}</q>
626
+ <span class="from">from ${sourceLinkHtml}</span>
627
+ </div>
659
628
  </div>
660
629
  `;
661
630
  }
662
- function renderCandidateComparison(state, presentationAdapter) {
663
- const current = candidateByRole(state.item, "current");
664
- const proposed = candidateByRole(state.item, "proposed");
631
+ function renderConfidenceMeter(confidence) {
632
+ const clamped = Math.max(0, Math.min(1, confidence));
633
+ const pct = Math.round(clamped * 100);
634
+ const isMid = clamped < 0.85;
665
635
  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>
636
+ <div class="conf" data-testid="confidence-meter">
637
+ <span>Confidence</span>
638
+ <span class="meter${isMid ? " mid" : ""}"><i style="width:${pct}%"></i></span>
639
+ <span class="pct">${clamped.toFixed(2)}</span>
640
+ </div>
679
641
  `;
680
642
  }
681
- function renderDecisionControls(state) {
682
- const activeDefinition = state.decision ? workbenchDecisionDefinitions[state.decision] : undefined;
643
+ /**
644
+ * The single collapsed power-user/audit surface per field card: reviewer note,
645
+ * decision effect, candidate/claim/source ids, and (once a decision has been made)
646
+ * the Surface projection preview and the raw ReviewDecision payload. Everything
647
+ * here is secondary detail tucked behind one `<details>` toggle, kept off the
648
+ * primary review surface.
649
+ */
650
+ function renderAuditDetails(item, current, proposed, session, presentationAdapter) {
651
+ const decision = session.decisionsByItemName[item.metadata.name];
652
+ const note = session.notesByItemName[item.metadata.name] ?? "";
653
+ const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
654
+ const state = {
655
+ item,
656
+ note,
657
+ decision,
658
+ editedValue: session.editedValuesByItemName?.[item.metadata.name],
659
+ reviewedAt: session.reviewedAt,
660
+ actorId: session.actorId,
661
+ };
662
+ const reviewDecisionPayload = buildReviewDecision(state);
663
+ const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
683
664
  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>
665
+ <details class="audit-details" data-testid="audit-details">
666
+ <summary>Audit details</summary>
667
+ <div class="audit-body">
668
+ <label class="note-field">
669
+ <span class="field-label">Reviewer note</span>
670
+ <textarea data-testid="reviewer-note" data-item-name="${escapeHtml(item.metadata.name)}">${escapeHtml(note)}</textarea>
671
+ </label>
672
+ ${definition ? `<p class="field-value"><span class="field-label">Decision effect</span> ${escapeHtml(definition.effect)}</p>` : ""}
673
+ ${renderProducerFeedbackTags(item)}
674
+ <dl class="field-stack compact">
675
+ ${current ? fieldItem("Current candidate ID", current.id) : ""}
676
+ ${proposed ? fieldItem("Proposed candidate ID", proposed.id) : ""}
677
+ ${proposed ? fieldItem("Claim ID", proposed.claimTarget.claimId ?? proposed.claimTarget.fieldOrBehavior) : ""}
678
+ ${proposed ? fieldItem("Raw Source ID", proposed.source.sourceId ?? proposed.source.sourceRef) : ""}
679
+ ${proposed?.locator ? fieldItem("Locator", proposed.locator.locator ?? proposed.locator.scheme) : ""}
680
+ ${proposed?.extraction.model ? fieldItem("Model", proposed.extraction.model) : ""}
681
+ ${proposed ? fieldItem("Extractor", proposed.extraction.extractor ?? "unknown") : ""}
682
+ ${proposed ? fieldItem("Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
683
+ </dl>
684
+ ${preview
685
+ ? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
686
+ : "<p class=\"preview-disclaimer\">No decision recorded yet — pick an option above to preview the saved record.</p>"}
687
+ ${reviewDecisionPayload
688
+ ? `<details class="reference-details">
689
+ <summary>Saved record (JSON)</summary>
690
+ <pre data-testid="decision-payload">${escapeHtml(JSON.stringify(reviewDecisionPayload, null, 2))}</pre>
691
+ </details>`
692
+ : ""}
691
693
  </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>
694
+ </details>
698
695
  `;
699
696
  }
700
- function renderFeedbackTags(state) {
701
- const tags = producerFeedbackTags(state.item);
697
+ function renderProducerFeedbackTags(item) {
698
+ const tags = item.spec.producerPolicy?.feedbackTags;
699
+ if (!Array.isArray(tags) || tags.length === 0) {
700
+ return "";
701
+ }
702
702
  return `
703
- <div class="feedback-tags" data-testid="producer-feedback-tags">
703
+ <div class="note-field">
704
704
  <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>
705
+ <p class="field-value">${tags.map((tag) => escapeHtml(tag)).join(", ")}</p>
708
706
  </div>
709
707
  `;
710
708
  }
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) {
709
+ function renderFooterTally(session) {
710
+ const counts = { review: 0, accepted: 0, kept: 0, rejected: 0 };
711
+ for (const item of session.items) {
712
+ const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
713
+ counts[state] += 1;
714
+ }
743
715
  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)}
716
+ <footer class="foot" data-testid="review-tally">
717
+ <div class="tally">
718
+ <span>Accepted <b data-testid="tally-accepted">${counts.accepted}</b></span>
719
+ <span>Kept <b data-testid="tally-kept">${counts.kept}</b></span>
720
+ <span>Flagged <b data-testid="tally-rejected">${counts.rejected}</b></span>
721
+ <span>Remaining <b data-testid="tally-review">${counts.review}</b></span>
751
722
  </div>
752
- <p class="preview-disclaimer preview-disclaimer-footer" data-testid="surface-preview-disclaimer">${escapeHtml(preview.postureDisclaimer)}</p>
753
- </details>
723
+ </footer>
754
724
  `;
755
725
  }
756
726
  function renderSurfacePreviewSections(preview) {
@@ -762,15 +732,6 @@ function renderSurfacePreviewSections(preview) {
762
732
  renderAuthorityTrace(preview),
763
733
  ].join("");
764
734
  }
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
735
  function renderReviewEvent(preview) {
775
736
  return `
776
737
  <section class="preview-section" data-testid="surface-review-event">
@@ -825,7 +786,7 @@ function renderCandidateHistory(preview) {
825
786
  return `
826
787
  <section class="preview-section" data-testid="surface-candidate-history">
827
788
  <h3>${escapeHtml("Unselected candidate history")}</h3>
828
- <div class="history-expander${overflowCandidates.length > 0 ? "" : ""}" data-history-expander>
789
+ <div class="history-expander" data-history-expander>
829
790
  <dl class="field-stack compact">
830
791
  ${renderHistoryRows(visibleCandidates)}
831
792
  ${overflowHtml}
@@ -840,9 +801,7 @@ function renderCandidateHistory(preview) {
840
801
  `;
841
802
  }
842
803
  function renderSourceEvidence(preview) {
843
- // Excerpt uses a 3-line clamp wrapper with a toggle button.
844
804
  const clampedExcerptHtml = fieldItemClamped("Excerpt", preview.sourceEvidence.excerpt, "excerpt");
845
- // Build Raw Source section with clamped excerpt inline.
846
805
  return `
847
806
  <section class="preview-section" data-testid="surface-source-evidence">
848
807
  <h3>${escapeHtml("Raw Source")}</h3>
@@ -897,11 +856,12 @@ function createReviewWorkbenchController(root, startState, options) {
897
856
  const persistEvents = () => {
898
857
  eventStore?.save(session, events);
899
858
  };
900
- const appendEvent = (eventType) => {
901
- const state = currentReviewWorkbenchState(session);
902
- const decision = state.decision;
903
- const candidate = decision ? candidateForDecision(state.item, decision) : undefined;
859
+ const appendEvent = (eventType, itemName, dataOverride) => {
860
+ const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
861
+ const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
862
+ const candidate = item && decision ? candidateForDecision(item, decision) : undefined;
904
863
  const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
864
+ const note = itemName ? session.notesByItemName[itemName] : undefined;
905
865
  events = [
906
866
  ...events,
907
867
  buildReviewSessionEvent(session, {
@@ -909,70 +869,74 @@ function createReviewWorkbenchController(root, startState, options) {
909
869
  sequence: events.length + 1,
910
870
  eventType,
911
871
  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,
872
+ reviewItemName: itemName,
873
+ reviewDecisionName: item && decision ? `${item.metadata.name}-${decision}` : undefined,
915
874
  candidateId: candidate?.id,
916
875
  status: definition?.status,
917
- rationale: state.note,
918
- data: decision ? { workbenchDecision: decision } : undefined,
876
+ rationale: note,
877
+ data: dataOverride ?? (decision ? { workbenchDecision: decision } : undefined),
919
878
  }),
920
879
  ];
921
880
  };
922
881
  const applySessionUpdate = (update) => {
923
882
  session = update(session);
924
883
  };
925
- const selectDecision = (decision) => {
884
+ const setDecision = (itemName, decision, rawEditedValue) => {
885
+ applySessionUpdate((current) => {
886
+ const item = current.items.find((entry) => entry.metadata.name === itemName);
887
+ const proposed = item?.spec.candidates.find((candidate) => candidate.role === "proposed");
888
+ const originalText = proposed ? formatValue(proposed.value) : undefined;
889
+ const nextEditedValuesByItemName = { ...current.editedValuesByItemName };
890
+ if (decision === "accept-proposed" && rawEditedValue !== undefined && rawEditedValue !== originalText) {
891
+ nextEditedValuesByItemName[itemName] = rawEditedValue;
892
+ }
893
+ else {
894
+ delete nextEditedValuesByItemName[itemName];
895
+ }
896
+ return {
897
+ ...current,
898
+ activeItemName: itemName,
899
+ decisionsByItemName: { ...current.decisionsByItemName, [itemName]: decision },
900
+ editedValuesByItemName: nextEditedValuesByItemName,
901
+ };
902
+ });
903
+ appendEvent("decision-changed", itemName);
904
+ persistEvents();
905
+ };
906
+ const clearDecision = (itemName) => {
907
+ applySessionUpdate((current) => {
908
+ const remainingDecisions = { ...current.decisionsByItemName };
909
+ delete remainingDecisions[itemName];
910
+ const remainingEdits = { ...current.editedValuesByItemName };
911
+ delete remainingEdits[itemName];
912
+ return {
913
+ ...current,
914
+ activeItemName: itemName,
915
+ decisionsByItemName: remainingDecisions,
916
+ editedValuesByItemName: remainingEdits,
917
+ };
918
+ });
919
+ appendEvent("decision-changed", itemName, { workbenchDecision: null });
920
+ persistEvents();
921
+ };
922
+ const updateReviewerNote = (itemName, note) => {
926
923
  applySessionUpdate((current) => ({
927
924
  ...current,
928
- decisionsByItemName: {
929
- ...current.decisionsByItemName,
930
- [currentReviewItem(current).metadata.name]: decision,
931
- },
925
+ notesByItemName: { ...current.notesByItemName, [itemName]: note },
932
926
  }));
933
- appendEvent("decision-changed");
927
+ appendEvent("note-changed", itemName);
934
928
  persistEvents();
935
929
  };
936
930
  const controller = {
937
- currentState: () => currentReviewWorkbenchState(session),
938
931
  currentSession: () => session,
939
932
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
940
- goToNextUnresolved: () => {
941
- applyNextUnresolvedSessionUpdate(applySessionUpdate, session);
942
- appendEvent("item-selected");
943
- persistEvents();
944
- },
945
933
  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
- },
934
+ setDecision,
935
+ clearDecision,
936
+ updateReviewerNote,
957
937
  };
958
938
  return controller;
959
939
  }
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
940
  function browserReviewSessionEventStore() {
977
941
  if (typeof window === "undefined" || !window.localStorage) {
978
942
  return undefined;
@@ -984,22 +948,9 @@ function reviewSessionStorageKey(session, keyPrefix) {
984
948
  return `${keyPrefix}:${itemNames}`;
985
949
  }
986
950
  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);
951
+ root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter });
952
+ bindFieldCardInteractions(root, controller);
953
+ bindApplyButton(root, controller);
1003
954
  bindClampToggles(root);
1004
955
  bindHistoryExpanders(root);
1005
956
  }
@@ -1012,41 +963,88 @@ function queueSessionFromStartState(startState) {
1012
963
  activeItemName: startState.item.metadata.name,
1013
964
  notesByItemName: startState.note ? { [startState.item.metadata.name]: startState.note } : {},
1014
965
  decisionsByItemName: startState.decision ? { [startState.item.metadata.name]: startState.decision } : {},
966
+ editedValuesByItemName: startState.editedValue !== undefined
967
+ ? { [startState.item.metadata.name]: startState.editedValue }
968
+ : {},
1015
969
  reviewedAt: startState.reviewedAt,
1016
970
  actorId: startState.actorId,
1017
971
  };
1018
972
  }
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) => {
973
+ function bindFieldCardInteractions(root, controller) {
974
+ root.querySelectorAll("[data-testid='use-proposed']").forEach((button) => {
1028
975
  button.addEventListener("click", () => {
1029
- selectDecision(button.dataset.decision);
976
+ const itemName = button.dataset.itemName ?? "";
977
+ const field = button.closest("[data-testid='review-field']");
978
+ const input = field?.querySelector("[data-testid='edit-proposed-value']");
979
+ controller.setDecision(itemName, "accept-proposed", input?.value);
980
+ controller.renderCurrentState();
1030
981
  });
1031
982
  });
1032
- }
1033
- function bindQueueRows(root, selectQueueItem) {
1034
- root.querySelectorAll("[data-item-name]").forEach((button) => {
983
+ root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
1035
984
  button.addEventListener("click", () => {
1036
- selectQueueItem(button.dataset.itemName ?? "");
1037
- scrollActiveReviewIntoView(root);
985
+ const itemName = button.dataset.itemName ?? "";
986
+ const field = button.closest("[data-testid='review-field']");
987
+ const wrong = field?.querySelector("[data-testid='wrong-toggle']");
988
+ const decision = wrong?.checked ? "reject-proposed" : "keep-current";
989
+ controller.setDecision(itemName, decision);
990
+ controller.renderCurrentState();
1038
991
  });
1039
992
  });
1040
- }
1041
- function bindNextUnresolved(root, goToNextUnresolved) {
1042
- root
1043
- .querySelectorAll("[data-testid='next-unresolved'], [data-testid='active-next-unresolved']")
1044
- .forEach((button) => {
993
+ root.querySelectorAll("[data-testid='undo-decision']").forEach((button) => {
1045
994
  button.addEventListener("click", () => {
1046
- goToNextUnresolved();
1047
- scrollActiveReviewIntoView(root);
995
+ controller.clearDecision(button.dataset.itemName ?? "");
996
+ controller.renderCurrentState();
1048
997
  });
1049
998
  });
999
+ root.querySelectorAll("[data-testid='reviewer-note']").forEach((textarea) => {
1000
+ textarea.addEventListener("input", () => {
1001
+ const itemName = textarea.dataset.itemName ?? "";
1002
+ controller.updateReviewerNote(itemName, textarea.value);
1003
+ refreshAuditPayloadForItem(textarea, controller, itemName);
1004
+ });
1005
+ });
1006
+ }
1007
+ function bindApplyButton(root, controller) {
1008
+ const button = root.querySelector("[data-testid='apply-button']");
1009
+ button?.addEventListener("click", () => {
1010
+ if (typeof CustomEvent !== "function" || typeof root.dispatchEvent !== "function") {
1011
+ return;
1012
+ }
1013
+ root.dispatchEvent(new CustomEvent("survey:review-workbench-apply", {
1014
+ bubbles: true,
1015
+ composed: true,
1016
+ detail: {
1017
+ session: controller.currentSession(),
1018
+ sessionExport: controller.currentSessionExport(),
1019
+ },
1020
+ }));
1021
+ });
1022
+ }
1023
+ /**
1024
+ * After a reviewer-note keystroke, patch just that field's decision-payload `<pre>`
1025
+ * in place instead of a full re-render — a full re-render would close the audit
1026
+ * `<details>` and drop focus/caret position on every keystroke.
1027
+ */
1028
+ function refreshAuditPayloadForItem(textarea, controller, itemName) {
1029
+ const field = textarea.closest("[data-testid='review-field']");
1030
+ const payload = field?.querySelector("[data-testid='decision-payload']");
1031
+ if (!payload) {
1032
+ return;
1033
+ }
1034
+ const session = controller.currentSession();
1035
+ const item = session.items.find((entry) => entry.metadata.name === itemName);
1036
+ if (!item) {
1037
+ return;
1038
+ }
1039
+ const state = {
1040
+ item,
1041
+ note: textarea.value,
1042
+ decision: session.decisionsByItemName[itemName],
1043
+ editedValue: session.editedValuesByItemName?.[itemName],
1044
+ reviewedAt: session.reviewedAt,
1045
+ actorId: session.actorId,
1046
+ };
1047
+ payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
1050
1048
  }
1051
1049
  function bindClampToggles(root) {
1052
1050
  root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {
@@ -1071,152 +1069,9 @@ function bindHistoryExpanders(root) {
1071
1069
  });
1072
1070
  });
1073
1071
  }
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
1072
  function fieldItemClamped(label, value, extraClass = "") {
1218
1073
  return `
1219
- <div class="field ${extraClass}">
1074
+ <div class="kv ${extraClass}">
1220
1075
  <dt class="field-label">${escapeHtml(label)}</dt>
1221
1076
  <div class="excerpt-clamp" data-clamp>
1222
1077
  <dd class="field-value">${escapeHtml(value)}</dd>
@@ -1227,15 +1082,12 @@ function fieldItemClamped(label, value, extraClass = "") {
1227
1082
  }
1228
1083
  function fieldItem(label, value, extraClass = "") {
1229
1084
  return `
1230
- <div class="field ${extraClass}">
1085
+ <div class="kv ${extraClass}">
1231
1086
  <dt class="field-label">${escapeHtml(label)}</dt>
1232
1087
  <dd class="field-value">${escapeHtml(value)}</dd>
1233
1088
  </div>
1234
1089
  `;
1235
1090
  }
1236
- function formatConfidence(value) {
1237
- return `${Math.round(value * 100)}% (${value.toFixed(2)})`;
1238
- }
1239
1091
  function escapeHtml(value) {
1240
1092
  return formatValue(value)
1241
1093
  .replaceAll("&", "&amp;")