@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.
- package/dist/example-data/public-directory-review-resource.d.ts +2 -0
- package/dist/example-data/public-directory-review-resource.js +1 -0
- package/dist/examples/review-workbench/downstream-public-directory-adapter.js +3 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/review-resource.d.ts +47 -0
- package/dist/src/review-workbench/review-queue-session.d.ts +30 -0
- package/dist/src/review-workbench/review-queue-session.js +30 -0
- package/dist/src/review-workbench/review-session-replay.js +7 -2
- package/dist/src/review-workbench/review-workbench-css.generated.js +615 -1417
- package/dist/src/review-workbench/review-workbench-data.d.ts +4 -0
- package/dist/src/review-workbench/review-workbench-data.js +172 -0
- package/dist/src/review-workbench/review-workbench-element.d.ts +5 -1
- package/dist/src/review-workbench/review-workbench-element.js +86 -3
- package/dist/src/review-workbench/review-workbench.css +615 -1417
- package/dist/src/review-workbench/review-workbench.d.ts +22 -2
- package/dist/src/review-workbench/review-workbench.js +513 -536
- package/dist/src/review-workbench/review-workbench.standalone.css +615 -1418
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalJson } from "./canonical.js";
|
|
2
|
-
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource,
|
|
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,
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
437
|
-
const
|
|
438
|
-
const
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
${
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
${
|
|
455
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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="
|
|
468
|
-
<div class="
|
|
469
|
-
<
|
|
470
|
-
<
|
|
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
|
-
<
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
data-testid="
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
<
|
|
535
|
-
|
|
536
|
-
<span>
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
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
|
-
<
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
<
|
|
609
|
-
<
|
|
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
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
const
|
|
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
|
-
<
|
|
636
|
-
<
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
const
|
|
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
|
-
<
|
|
685
|
-
<
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
|
701
|
-
const tags =
|
|
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="
|
|
809
|
+
<div class="note-field">
|
|
704
810
|
<span class="field-label">Producer feedback tags</span>
|
|
705
|
-
<
|
|
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
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
<
|
|
745
|
-
<
|
|
746
|
-
<span
|
|
747
|
-
<span
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
902
|
-
const decision =
|
|
903
|
-
const candidate = decision ? candidateForDecision(
|
|
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:
|
|
913
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
929
|
-
...current.decisionsByItemName,
|
|
930
|
-
[currentReviewItem(current).metadata.name]: decision,
|
|
931
|
-
},
|
|
1031
|
+
notesByItemName: { ...current.notesByItemName, [itemName]: note },
|
|
932
1032
|
}));
|
|
933
|
-
appendEvent("
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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,
|
|
988
|
-
|
|
989
|
-
|
|
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
|
|
1020
|
-
root.
|
|
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
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
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="
|
|
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="
|
|
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("&", "&")
|