@kontourai/survey 6.0.0 → 7.0.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/src/candidate-verification.d.ts +175 -0
- package/dist/src/candidate-verification.js +334 -0
- package/dist/src/canonical-reviewed-trust-input.js +30 -1
- package/dist/src/console/review-console-server.js +22 -4
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.js +3 -2
- package/dist/src/mcp/review-mcp.js +161 -44
- package/dist/src/review-resource.d.ts +16 -0
- package/dist/src/review-session-file.d.ts +7 -0
- package/dist/src/review-workbench/audit-rows.d.ts +1 -1
- package/dist/src/review-workbench/audit-rows.js +1 -0
- package/dist/src/review-workbench/queue-binding.d.ts +45 -2
- package/dist/src/review-workbench/queue-binding.js +45 -0
- package/dist/src/review-workbench/review-presentation.d.ts +60 -0
- package/dist/src/review-workbench/review-presentation.js +107 -10
- package/dist/src/review-workbench/review-queue-session.d.ts +47 -4
- package/dist/src/review-workbench/review-queue-session.js +73 -8
- package/dist/src/review-workbench/review-session-replay.d.ts +11 -1
- package/dist/src/review-workbench/review-session-replay.js +35 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +96 -1
- package/dist/src/review-workbench/review-workbench-element.d.ts +10 -0
- package/dist/src/review-workbench/review-workbench-element.js +21 -3
- package/dist/src/review-workbench/review-workbench.css +96 -1
- package/dist/src/review-workbench/review-workbench.d.ts +28 -8
- package/dist/src/review-workbench/review-workbench.js +174 -63
- package/dist/src/review-workbench/review-workbench.standalone.css +96 -1
- package/dist/src/review-workbench/server-review-session.d.ts +18 -0
- package/dist/src/review-workbench/server-review-session.js +20 -2
- package/dist/src/surface-reviewed-extraction.d.ts +23 -0
- package/dist/src/surface-reviewed-extraction.js +64 -1
- package/dist/src/to-surface.js +32 -3
- package/package.json +1 -1
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { canonicalJson } from "./canonical.js";
|
|
2
2
|
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
3
|
-
import { candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
3
|
+
import { candidateForDecision, conflictSelectionIssue, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, unchosenProposedCandidates, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewWorkbenchState, defaultReviewSessionName, effectiveValueForDecision, initialReviewQueueSessionState, replayReviewSessionEvents, reviewWorkbenchSessionStorageKey, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
4
4
|
import { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
5
5
|
import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
|
|
6
6
|
import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
|
|
7
7
|
import { findSoleCandidateById, reviewResourceApiVersion, } from "../review-resource.js";
|
|
8
8
|
import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
|
|
9
|
-
import {
|
|
9
|
+
import { attestReviewQueueExtraction } from "./queue-binding.js";
|
|
10
|
+
import { candidateVerificationNotes, excludedProposalsSentence, excludedProposalsUnreadableSentence } from "./review-presentation.js";
|
|
10
11
|
import { editedValueFromEditorText, isIsoCalendarDate, parsePlainDecimal } from "./edited-value.js";
|
|
11
12
|
import { createAuditFactTrace, } from "./audit-rows.js";
|
|
12
13
|
export { reviewAuditRowKeys } from "./audit-rows.js";
|
|
13
|
-
export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
|
|
14
|
+
export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, attestReviewQueueExtraction, bindReviewQueue, reviewItemCarriesExtractionBinding, unverifiedExtractionQueueSentence, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./queue-binding.js";
|
|
14
15
|
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
|
|
15
|
-
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
16
|
+
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, conflictSelectionIssue, unchosenProposedCandidates, decisionCandidateId, decisionSelectsNoCandidate, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
16
17
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
|
|
17
18
|
export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
|
|
18
19
|
export { reviewSessionReplayWarningsForSnapshot, validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
19
20
|
export { checkEditedValueForItem, editedValueFromEditorText, } from "./edited-value.js";
|
|
20
21
|
export { assertReviewDecisionModeAllows, DecisionModeViolationError, validateReviewDecisionMode, } from "./producer-decision-mode.js";
|
|
21
|
-
export function buildReviewDecision(state) {
|
|
22
|
+
export function buildReviewDecision(state, options = {}) {
|
|
22
23
|
if (!state.decision) {
|
|
23
24
|
return undefined;
|
|
24
25
|
}
|
|
@@ -26,7 +27,8 @@ export function buildReviewDecision(state) {
|
|
|
26
27
|
if (state.decision === "could-not-confirm" && !state.note.trim()) {
|
|
27
28
|
throw new Error("Could not confirm requires a non-empty reason.");
|
|
28
29
|
}
|
|
29
|
-
const candidate = candidateForDecision(state.item, state.decision);
|
|
30
|
+
const candidate = candidateForDecision(state.item, state.decision, state.selectedCandidateId);
|
|
31
|
+
const unchosen = unchosenProposedCandidates(state.item, state.decision, state.selectedCandidateId);
|
|
30
32
|
// A decision that selects no candidate (a value-neutral decision on a
|
|
31
33
|
// conflict) records no candidate id and no candidate's projection hints.
|
|
32
34
|
const selectsNone = decisionSelectsNoCandidate(state.item, state.decision);
|
|
@@ -36,7 +38,7 @@ export function buildReviewDecision(state) {
|
|
|
36
38
|
?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
|
|
37
39
|
};
|
|
38
40
|
const candidateProjection = selectsNone ? undefined : candidate.projection;
|
|
39
|
-
const authorizing = buildDecisionCardAuthorizing(state);
|
|
41
|
+
const authorizing = buildDecisionCardAuthorizing(state, options.presentationAdapter);
|
|
40
42
|
const reviewDecision = {
|
|
41
43
|
apiVersion: reviewResourceApiVersion,
|
|
42
44
|
kind: "ReviewDecision",
|
|
@@ -66,6 +68,8 @@ export function buildReviewDecision(state) {
|
|
|
66
68
|
...(state.decision === "accept-proposed" && state.editedValue !== undefined
|
|
67
69
|
? { editedValue: state.editedValue }
|
|
68
70
|
: {}),
|
|
71
|
+
// A choice between conflicting values records the rivals it passed over.
|
|
72
|
+
...(unchosen.length ? { unselectedCandidateIds: unchosen.map((entry) => entry.id) } : {}),
|
|
69
73
|
},
|
|
70
74
|
status: {
|
|
71
75
|
...(candidateProjection?.claimId ? { appliedToClaimIds: [candidateProjection.claimId] } : {}),
|
|
@@ -91,12 +95,11 @@ const DECISION_CARD_PROMPT_REF = "review-workbench/decision-card@v1";
|
|
|
91
95
|
* On validation failure, emits a console warning and returns undefined so the
|
|
92
96
|
* outcome is recorded without authorizing (transparency-gap-not-blocker per ADR 0004).
|
|
93
97
|
*/
|
|
94
|
-
function buildDecisionCardAuthorizing(state) {
|
|
98
|
+
function buildDecisionCardAuthorizing(state, adapter) {
|
|
95
99
|
if (!state.decision) {
|
|
96
100
|
return undefined;
|
|
97
101
|
}
|
|
98
|
-
const
|
|
99
|
-
const renderedPrompt = decisionCardRenderedPrompt(state, targetLabel);
|
|
102
|
+
const renderedPrompt = decisionCardRenderedPrompt(state, buildReviewItemPresentation(state.item, adapter), adapter);
|
|
100
103
|
// Reviewer note present means the reviewer also typed a rationale — "typed".
|
|
101
104
|
// Control-only affirmation (no note) is "affirmed-control".
|
|
102
105
|
const action = state.note?.trim() ? "typed" : "affirmed-control";
|
|
@@ -121,34 +124,47 @@ function buildDecisionCardAuthorizing(state) {
|
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
127
|
+
* Renders the decision card's prompt for a review item and decision.
|
|
128
|
+
*
|
|
129
|
+
* It is rebuilt from the item when the decision is built, not captured from
|
|
130
|
+
* what a reviewer's screen showed: it records what the card states for this
|
|
131
|
+
* item, with the same presentation adapter, target label and value text.
|
|
126
132
|
*
|
|
127
133
|
* Format mirrors the review question shown in the workbench header:
|
|
128
134
|
* "For {target}, decide whether {proposed} should replace {current}."
|
|
129
135
|
* followed by the selected decision label so the block is self-contained.
|
|
130
136
|
*/
|
|
131
|
-
function decisionCardRenderedPrompt(state,
|
|
132
|
-
// The card
|
|
133
|
-
//
|
|
134
|
-
const excluded = excludedProposalsSentence(
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
function decisionCardRenderedPrompt(state, presentation, adapter) {
|
|
138
|
+
// The card states proposals excluded at import, and stored ones it cannot
|
|
139
|
+
// show; so does the prompt.
|
|
140
|
+
const excluded = excludedProposalsSentence(presentation.excludedProposals);
|
|
141
|
+
const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
|
|
142
|
+
// Likewise the verifier records the card shows, read against the same value.
|
|
143
|
+
const verification = candidateVerificationNotes(state.item, state.decision === "accept-proposed" ? state.editedValue : undefined).map((note) => note.sentence);
|
|
144
|
+
return [decisionCardBasePrompt(state, presentation.targetLabel, adapter), ...(excluded ? [excluded] : []), ...(unreadable ? [unreadable] : []), ...verification].join(" ");
|
|
145
|
+
}
|
|
146
|
+
function decisionCardBasePrompt(state, targetLabel, adapter) {
|
|
147
|
+
const valueText = (candidate) => buildReviewCandidatePresentation(state.item, candidate, adapter, targetLabel).valueText;
|
|
139
148
|
const currentCandidate = state.item.spec.candidates.find((c) => c.role === "current");
|
|
140
149
|
const proposedCandidates = state.item.spec.candidates.filter((c) => c.role === "proposed");
|
|
141
|
-
// A conflict card lists every proposed value and offers
|
|
142
|
-
// not confirm; the prompt states the same
|
|
150
|
+
// A conflict card lists every proposed value and offers a choice of one,
|
|
151
|
+
// reject-all or could not confirm; the prompt states the same: every value,
|
|
152
|
+
// and for a choice, which value was chosen and which were not.
|
|
143
153
|
if (proposedCandidates.length > 1) {
|
|
154
|
+
const values = `For ${targetLabel}, ${proposedCandidates.length} different values were proposed: ${proposedCandidates.map(valueText).join(", ")}.`;
|
|
155
|
+
if (state.decision === "select-proposed") {
|
|
156
|
+
const chosen = proposedCandidates.find((c) => c.id === state.selectedCandidateId);
|
|
157
|
+
const unchosen = proposedCandidates.filter((c) => c.id !== state.selectedCandidateId);
|
|
158
|
+
return `${values} Selected decision: ${workbenchDecisionDefinitions[state.decision].label}: ${chosen ? valueText(chosen) : formatValue("")} (not chosen: ${unchosen.map(valueText).join(", ")}).`;
|
|
159
|
+
}
|
|
144
160
|
const decisionLabel = state.decision === "reject-proposed"
|
|
145
161
|
? "Reject all values"
|
|
146
162
|
: state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
|
|
147
|
-
return
|
|
163
|
+
return `${values} Selected decision: ${decisionLabel}.`;
|
|
148
164
|
}
|
|
149
165
|
const proposedCandidate = proposedCandidates[0];
|
|
150
|
-
const currentValue =
|
|
151
|
-
const proposedValue =
|
|
166
|
+
const currentValue = currentCandidate ? valueText(currentCandidate) : formatValue("");
|
|
167
|
+
const proposedValue = proposedCandidate ? valueText(proposedCandidate) : formatValue("");
|
|
152
168
|
const decisionLabel = state.decision ? workbenchDecisionDefinitions[state.decision].label : "";
|
|
153
169
|
return `For ${targetLabel}, decide whether ${proposedValue} should replace ${currentValue}. Selected decision: ${decisionLabel}.`;
|
|
154
170
|
}
|
|
@@ -160,7 +176,7 @@ export class ReviewApplyActionMappingError extends Error {
|
|
|
160
176
|
this.issues = issues;
|
|
161
177
|
}
|
|
162
178
|
}
|
|
163
|
-
export function buildReviewDecisionsFromSession(session) {
|
|
179
|
+
export function buildReviewDecisionsFromSession(session, options = {}) {
|
|
164
180
|
return session.items.flatMap((item) => {
|
|
165
181
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
166
182
|
if (!decision) {
|
|
@@ -169,7 +185,7 @@ export function buildReviewDecisionsFromSession(session) {
|
|
|
169
185
|
const reviewDecision = buildReviewDecision(currentReviewWorkbenchState({
|
|
170
186
|
...session,
|
|
171
187
|
activeItemName: item.metadata.name,
|
|
172
|
-
}));
|
|
188
|
+
}), options);
|
|
173
189
|
return reviewDecision ? [reviewDecision] : [];
|
|
174
190
|
});
|
|
175
191
|
}
|
|
@@ -290,7 +306,7 @@ function matchingSelectedCandidate(item, result) {
|
|
|
290
306
|
function structuralEqual(left, right) {
|
|
291
307
|
return canonicalJson(left) === canonicalJson(right);
|
|
292
308
|
}
|
|
293
|
-
export function buildReviewWorkbenchResultsFromSession(session) {
|
|
309
|
+
export function buildReviewWorkbenchResultsFromSession(session, options = {}) {
|
|
294
310
|
return session.items.flatMap((item) => {
|
|
295
311
|
const decision = session.decisionsByItemName[item.metadata.name];
|
|
296
312
|
if (!decision) {
|
|
@@ -300,7 +316,7 @@ export function buildReviewWorkbenchResultsFromSession(session) {
|
|
|
300
316
|
...session,
|
|
301
317
|
activeItemName: item.metadata.name,
|
|
302
318
|
});
|
|
303
|
-
const reviewDecision = buildReviewDecision(state);
|
|
319
|
+
const reviewDecision = buildReviewDecision(state, options);
|
|
304
320
|
if (!reviewDecision) {
|
|
305
321
|
return [];
|
|
306
322
|
}
|
|
@@ -314,11 +330,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
|
|
|
314
330
|
rationale: reviewDecision.spec.rationale,
|
|
315
331
|
}];
|
|
316
332
|
}
|
|
317
|
-
const
|
|
333
|
+
const selectedCandidateId = session.selectedCandidateIdsByItemName?.[item.metadata.name];
|
|
334
|
+
const selectedCandidate = candidateForDecision(item, decision, selectedCandidateId);
|
|
318
335
|
const editedValue = decision === "accept-proposed"
|
|
319
336
|
? session.editedValuesByItemName?.[item.metadata.name]
|
|
320
337
|
: undefined;
|
|
321
|
-
const effectiveValue = effectiveValueForDecision(item, decision, editedValue);
|
|
338
|
+
const effectiveValue = effectiveValueForDecision(item, decision, editedValue, selectedCandidateId);
|
|
322
339
|
return [{
|
|
323
340
|
reviewItemName: item.metadata.name,
|
|
324
341
|
decision,
|
|
@@ -337,12 +354,12 @@ export function buildReviewWorkbenchResultsFromSession(session) {
|
|
|
337
354
|
}];
|
|
338
355
|
});
|
|
339
356
|
}
|
|
340
|
-
export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session)) {
|
|
357
|
+
export function buildReviewWorkbenchSessionExport(session, events = buildReviewSessionEvents(session), options = {}) {
|
|
341
358
|
return {
|
|
342
359
|
session: buildReviewSessionResource(session, events),
|
|
343
360
|
events,
|
|
344
|
-
decisions: buildReviewDecisionsFromSession(session),
|
|
345
|
-
results: buildReviewWorkbenchResultsFromSession(session),
|
|
361
|
+
decisions: buildReviewDecisionsFromSession(session, options),
|
|
362
|
+
results: buildReviewWorkbenchResultsFromSession(session, options),
|
|
346
363
|
};
|
|
347
364
|
}
|
|
348
365
|
export function replayReviewSessionEventsForSnapshot(snapshot, events) {
|
|
@@ -352,9 +369,9 @@ export function replayReviewSessionEventsForSnapshot(snapshot, events) {
|
|
|
352
369
|
}
|
|
353
370
|
return replayReviewSessionEvents(snapshot, events);
|
|
354
371
|
}
|
|
355
|
-
export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events) {
|
|
372
|
+
export function buildReviewWorkbenchSessionExportForSnapshot(snapshot, events, options = {}) {
|
|
356
373
|
const replayedSession = replayReviewSessionEventsForSnapshot(snapshot, events);
|
|
357
|
-
return buildReviewWorkbenchSessionExport(replayedSession, events);
|
|
374
|
+
return buildReviewWorkbenchSessionExport(replayedSession, events, options);
|
|
358
375
|
}
|
|
359
376
|
export function deriveReviewSessionApplyResultForSnapshot(options) {
|
|
360
377
|
const requiredResolvedItems = options.requiredResolvedItems ?? "none";
|
|
@@ -370,7 +387,7 @@ export function deriveReviewSessionApplyResultForSnapshot(options) {
|
|
|
370
387
|
}
|
|
371
388
|
const warnings = reviewSessionReplayWarningsForSnapshot(options.snapshot, options.events);
|
|
372
389
|
const replayedSession = replayReviewSessionEvents(options.snapshot, options.events);
|
|
373
|
-
const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events);
|
|
390
|
+
const sessionExport = buildReviewWorkbenchSessionExport(replayedSession, options.events, { presentationAdapter: options.presentationAdapter });
|
|
374
391
|
const resolvedItemNames = new Set(sessionExport.results.map((result) => result.reviewItemName));
|
|
375
392
|
const unresolvedItemNames = options.snapshot.items
|
|
376
393
|
.map((item) => item.metadata.name)
|
|
@@ -497,7 +514,7 @@ export function createPersistentReviewSessionEventStore(options) {
|
|
|
497
514
|
}
|
|
498
515
|
export function renderReviewWorkbenchHtml(state, _events, options = {}) {
|
|
499
516
|
const session = queueSessionFromStartState(state);
|
|
500
|
-
return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView));
|
|
517
|
+
return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView), attestReviewQueueExtraction(session.items, options.extractionImport));
|
|
501
518
|
}
|
|
502
519
|
/**
|
|
503
520
|
* The visual/decision state of a field card, derived from the reviewer's local
|
|
@@ -505,7 +522,7 @@ export function renderReviewWorkbenchHtml(state, _events, options = {}) {
|
|
|
505
522
|
* candidate set (e.g. a pre-decided item seeded by the host) as already-kept.
|
|
506
523
|
*/
|
|
507
524
|
function fieldCardState(item, decision) {
|
|
508
|
-
if (decision === "accept-proposed")
|
|
525
|
+
if (decision === "accept-proposed" || decision === "select-proposed")
|
|
509
526
|
return "accepted";
|
|
510
527
|
if (decision === "keep-current")
|
|
511
528
|
return "kept";
|
|
@@ -527,7 +544,9 @@ function fieldCardState(item, decision) {
|
|
|
527
544
|
*/
|
|
528
545
|
function chipLabel(state, hasCurrentValue, conflictingValues) {
|
|
529
546
|
switch (state) {
|
|
530
|
-
|
|
547
|
+
// On a conflict the only accepting decision is a choice of one value, and
|
|
548
|
+
// the chip says it was a choice, never a plain accept.
|
|
549
|
+
case "accepted": return conflictingValues ? `Chose 1 of ${conflictingValues} values` : "Accepted";
|
|
531
550
|
case "kept": return hasCurrentValue ? "Kept current" : conflictingValues ? "All values rejected" : "Left unset";
|
|
532
551
|
case "rejected": return hasCurrentValue ? "Kept — flagged wrong" : conflictingValues ? "All values rejected" : "Left unset";
|
|
533
552
|
case "could-not-confirm": return "Could not confirm";
|
|
@@ -583,7 +602,16 @@ function isEmptyValue(value) {
|
|
|
583
602
|
}
|
|
584
603
|
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>";
|
|
585
604
|
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>";
|
|
586
|
-
function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
|
|
605
|
+
function renderReviewQueueSessionHtml(session, presentationAdapter, queueView, attestation) {
|
|
606
|
+
// A queue that does not match its stored extraction import is not reviewed:
|
|
607
|
+
// its items could hide or alter what the source said.
|
|
608
|
+
if (attestation.state === "diverges") {
|
|
609
|
+
return `
|
|
610
|
+
<section class="workbench-shell review" data-testid="review-workbench-shell" data-queue-attestation="diverges" aria-label="Survey review workbench">
|
|
611
|
+
<div class="queue-attestation diverges" data-testid="queue-attestation" data-state="diverges" role="alert">${WARNING_SVG}<span>${escapeHtml(attestation.message)}</span></div>
|
|
612
|
+
</section>
|
|
613
|
+
`;
|
|
614
|
+
}
|
|
587
615
|
const totalCount = session.items.length;
|
|
588
616
|
const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
|
|
589
617
|
|| item.spec.candidateSetStatus === "resolved").length;
|
|
@@ -593,7 +621,10 @@ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
|
|
|
593
621
|
const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
|
|
594
622
|
const window = buildReviewQueueWindow(session, queueView);
|
|
595
623
|
return `
|
|
596
|
-
<section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
|
|
624
|
+
<section class="workbench-shell review" data-testid="review-workbench-shell" data-queue-attestation="${attestation.state}" aria-label="Survey review workbench">
|
|
625
|
+
${attestation.state === "unverified"
|
|
626
|
+
? `<div class="queue-attestation unverified" data-testid="queue-attestation" data-state="unverified" role="note">${WARNING_SVG}<span>${escapeHtml(attestation.message)}</span></div>`
|
|
627
|
+
: ""}
|
|
597
628
|
<header class="rhead">
|
|
598
629
|
<div class="top">
|
|
599
630
|
<div class="subj">
|
|
@@ -653,9 +684,9 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
653
684
|
const decided = decision !== undefined;
|
|
654
685
|
const current = item.spec.candidates.find((candidate) => candidate.role === "current");
|
|
655
686
|
const proposedCandidates = item.spec.candidates.filter((candidate) => candidate.role === "proposed");
|
|
656
|
-
// Several proposed values for one claim are a conflict.
|
|
657
|
-
//
|
|
658
|
-
//
|
|
687
|
+
// Several proposed values for one claim are a conflict. The reviewer can
|
|
688
|
+
// choose one of them by candidate id (select-proposed), reject them all, or
|
|
689
|
+
// end the round as could-not-confirm; no role-based control may pick one.
|
|
659
690
|
const conflict = proposedCandidates.length > 1;
|
|
660
691
|
const proposed = proposedCandidates.length === 1 ? proposedCandidates[0] : undefined;
|
|
661
692
|
const presentation = buildReviewItemPresentation(item, presentationAdapter);
|
|
@@ -697,10 +728,11 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
697
728
|
${proposed
|
|
698
729
|
? renderDiffRow(item, current, proposed, presentation.targetLabel, decided, effectiveProposedText, currentPresentationText)
|
|
699
730
|
: proposedCandidates.length > 1
|
|
700
|
-
? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel)
|
|
731
|
+
? renderConflictingProposals(item, proposedCandidates, presentationAdapter, presentation.targetLabel, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name])
|
|
701
732
|
: "<p class=\"field-value\">No proposed value is available for this field.</p>"}
|
|
702
733
|
${proposed ? renderProvenanceRow(item, proposed, presentationAdapter) : ""}
|
|
703
734
|
${renderExtractionImportNotes(presentation)}
|
|
735
|
+
${renderVerificationNotes(candidateVerificationNotes(item, decision === "accept-proposed" ? editedValue : undefined))}
|
|
704
736
|
<div class="decide">
|
|
705
737
|
${keepDecision === undefined ? "" : `<button class="btn keep" type="button" data-testid="keep-current" data-item-name="${escapeHtml(item.metadata.name)}">${keepLabel}</button>`}
|
|
706
738
|
${proposed ? `<button class="btn use" type="button" data-testid="use-proposed" data-item-name="${escapeHtml(item.metadata.name)}">Use proposed</button>
|
|
@@ -730,18 +762,37 @@ function renderFieldCard(item, session, presentationAdapter) {
|
|
|
730
762
|
}
|
|
731
763
|
/**
|
|
732
764
|
* The card body for an item whose candidate set holds several proposed values.
|
|
733
|
-
* Lists every value with its excerpt
|
|
734
|
-
*
|
|
765
|
+
* Lists every value with its excerpt. Until the field is decided, each value
|
|
766
|
+
* has its own "Use this value" control, which records `select-proposed` with
|
|
767
|
+
* that candidate's id. Once a value is chosen, every value stays listed and is
|
|
768
|
+
* marked chosen or not chosen, so the card never reads as if there had been
|
|
769
|
+
* one value. Proposals excluded at import are not candidates: they are listed
|
|
770
|
+
* by {@link renderExtractionImportNotes} and cannot be chosen.
|
|
735
771
|
*/
|
|
736
|
-
function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel) {
|
|
772
|
+
function renderConflictingProposals(item, candidates, presentationAdapter, targetLabel, decision, selectedCandidateId) {
|
|
773
|
+
const chose = decision === "select-proposed";
|
|
737
774
|
const values = candidates.map((candidate) => {
|
|
738
775
|
const text = buildReviewCandidatePresentation(item, candidate, presentationAdapter, targetLabel).valueText;
|
|
739
776
|
const excerpt = candidate.locator?.excerpt;
|
|
740
|
-
|
|
777
|
+
const chosen = chose && candidate.id === selectedCandidateId;
|
|
778
|
+
const mark = chose
|
|
779
|
+
? ` <span class="choice-tag ${chosen ? "chosen" : "not-chosen"}" data-testid="choice-tag">${chosen ? "Chosen" : "Not chosen"}</span>`
|
|
780
|
+
: "";
|
|
781
|
+
const control = decision === undefined
|
|
782
|
+
? `<button class="btn choose" type="button" data-testid="select-value" data-item-name="${escapeHtml(item.metadata.name)}" data-candidate-id="${escapeHtml(candidate.id)}" aria-label="Use ${escapeHtml(text)} for ${escapeHtml(targetLabel)}">Use this value</button>`
|
|
783
|
+
: "";
|
|
784
|
+
return `<li data-testid="conflicting-value" data-candidate-id="${escapeHtml(candidate.id)}"${chose ? ` data-chosen="${chosen ? "true" : "false"}"` : ""}>
|
|
785
|
+
<span class="conflict-text"><span class="vtext">${escapeHtml(text)}</span>${excerpt ? ` <q>${escapeHtml(excerpt)}</q>` : ""}${mark}</span>${control}
|
|
786
|
+
</li>`;
|
|
741
787
|
}).join("");
|
|
788
|
+
const intro = decision === undefined
|
|
789
|
+
? `${candidates.length} different values were proposed for this field. Use the value the source supports, reject them all, or mark the field Could not confirm with a reason.`
|
|
790
|
+
: chose
|
|
791
|
+
? `${candidates.length} different values were proposed for this field. One was chosen; ${candidates.length === 2 ? "the other was" : "the others were"} seen and not chosen.`
|
|
792
|
+
: `${candidates.length} different values were proposed for this field.`;
|
|
742
793
|
return `
|
|
743
|
-
<div class="field-value" data-testid="conflicting-proposals">
|
|
744
|
-
<p>${
|
|
794
|
+
<div class="field-value conflict-values" data-testid="conflicting-proposals">
|
|
795
|
+
<p>${escapeHtml(intro)}</p>
|
|
745
796
|
<ul>${values}</ul>
|
|
746
797
|
</div>
|
|
747
798
|
`;
|
|
@@ -942,7 +993,8 @@ function renderProvenanceRow(item, proposed, presentationAdapter) {
|
|
|
942
993
|
*/
|
|
943
994
|
function renderExtractionImportNotes(presentation) {
|
|
944
995
|
const { excerptVerification: verification, excludedProposals: excluded } = presentation;
|
|
945
|
-
|
|
996
|
+
const unreadable = excludedProposalsUnreadableSentence(presentation.excludedProposalsUnreadable);
|
|
997
|
+
if (verification === undefined && excluded.length === 0 && unreadable === undefined)
|
|
946
998
|
return "";
|
|
947
999
|
const excludedHtml = excluded.length === 0 ? "" : `
|
|
948
1000
|
<div class="noprov" data-testid="excluded-proposals" role="note">
|
|
@@ -952,12 +1004,32 @@ function renderExtractionImportNotes(presentation) {
|
|
|
952
1004
|
${excluded.map((entry) => `<span data-testid="excluded-proposal" data-proposal-index="${escapeHtml(String(entry.proposalIndex))}"><q>${escapeHtml(entry.valueText)}</q> (proposal ${escapeHtml(String(entry.proposalIndex))}, ${escapeHtml(entry.locator)})</span>`).join(", ")}.
|
|
953
1005
|
Unverifiable is not disproven: check the source before deciding.</span>
|
|
954
1006
|
</div>`;
|
|
1007
|
+
const unreadableHtml = unreadable === undefined ? "" : `
|
|
1008
|
+
<div class="noprov" data-testid="excluded-proposals-unreadable" data-reason="${escapeHtml(presentation.excludedProposalsUnreadable.reason)}" role="note">
|
|
1009
|
+
${WARNING_SVG}
|
|
1010
|
+
<span class="tag">${presentation.excludedProposalsUnreadable.reason === "binding-broken" ? "Binding broken" : "Unreadable"}</span>
|
|
1011
|
+
<span>${escapeHtml(unreadable)} Check the source before deciding.</span>
|
|
1012
|
+
</div>`;
|
|
955
1013
|
const verificationHtml = verification === undefined ? "" : `
|
|
956
1014
|
<p class="excerpt-verification" data-testid="excerpt-verification" data-verified="${verification === "verified" ? "true" : "false"}">${verification === "verified"
|
|
957
1015
|
? "Excerpts checked against the prepared source text at import."
|
|
958
1016
|
: "Excerpts not checked against the prepared source text at import."}</p>`;
|
|
959
1017
|
return `
|
|
960
|
-
<div class="prov import-notes">${excludedHtml}${verificationHtml}
|
|
1018
|
+
<div class="prov import-notes">${excludedHtml}${unreadableHtml}${verificationHtml}
|
|
1019
|
+
</div>
|
|
1020
|
+
`;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* What verifier records say about the value on this card. A record is what a
|
|
1024
|
+
* verifier said, not a review decision; records bound to another value and
|
|
1025
|
+
* records that failed validation are counted, never shown as verdicts.
|
|
1026
|
+
*/
|
|
1027
|
+
function renderVerificationNotes(notes) {
|
|
1028
|
+
if (notes.length === 0)
|
|
1029
|
+
return "";
|
|
1030
|
+
return `
|
|
1031
|
+
<div class="prov import-notes">${notes.map((note) => `
|
|
1032
|
+
<p class="support-verification" data-testid="support-verification" data-candidate-id="${escapeHtml(note.candidateId)}" data-status="${note.status}">${escapeHtml(note.sentence)}</p>`).join("")}
|
|
961
1033
|
</div>
|
|
962
1034
|
`;
|
|
963
1035
|
}
|
|
@@ -989,10 +1061,11 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
|
|
|
989
1061
|
note,
|
|
990
1062
|
decision,
|
|
991
1063
|
editedValue: session.editedValuesByItemName?.[item.metadata.name],
|
|
1064
|
+
selectedCandidateId: session.selectedCandidateIdsByItemName?.[item.metadata.name],
|
|
992
1065
|
reviewedAt: session.reviewedAt,
|
|
993
1066
|
actorId: session.actorId,
|
|
994
1067
|
};
|
|
995
|
-
const reviewDecisionPayload = buildReviewDecision(state);
|
|
1068
|
+
const reviewDecisionPayload = buildReviewDecision(state, { presentationAdapter });
|
|
996
1069
|
const preview = buildSurfaceProjectionPreview(item, reviewDecisionPayload, presentationAdapter);
|
|
997
1070
|
// Seeded with what the card face already shows, so the audit surface never
|
|
998
1071
|
// reprints it. Passed down through every section: the sections render in DOM
|
|
@@ -1017,7 +1090,12 @@ function renderAuditDetails(item, current, proposed, session, presentationAdapte
|
|
|
1017
1090
|
${proposed?.extraction.model ? fieldItem("model", "Model", proposed.extraction.model) : ""}
|
|
1018
1091
|
${proposed ? placementItem(trace, { of: proposed.id, property: "extraction.extractor" }, "extractor", "Extractor", proposed.extraction.extractor ?? "unknown") : ""}
|
|
1019
1092
|
${proposed ? fieldItem("extracted-at", "Extracted at", proposed.extraction.extractedAt ?? "unknown") : ""}
|
|
1020
|
-
${
|
|
1093
|
+
${(() => {
|
|
1094
|
+
const itemPresentation = buildReviewItemPresentation(item, presentationAdapter);
|
|
1095
|
+
const unreadable = excludedProposalsUnreadableSentence(itemPresentation.excludedProposalsUnreadable);
|
|
1096
|
+
return itemPresentation.excludedProposals.map((entry) => fieldItem("excluded-proposal", `Excluded proposal ${entry.proposalIndex}`, `${entry.valueText} at ${entry.locator}: excerpt not at the cited span`)).join("")
|
|
1097
|
+
+ (unreadable ? fieldItem("excluded-proposals-unreadable", "Excluded proposals not shown", unreadable) : "");
|
|
1098
|
+
})()}
|
|
1021
1099
|
</dl>
|
|
1022
1100
|
${preview
|
|
1023
1101
|
? `<div class="preview-section-grid">${renderSurfacePreviewSections(preview, trace)}</div><p class="preview-disclaimer preview-disclaimer-footer">${escapeHtml(preview.postureDisclaimer)}</p>`
|
|
@@ -1241,6 +1319,9 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1241
1319
|
let events = eventStore?.load(baseSession) ?? [];
|
|
1242
1320
|
let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
|
|
1243
1321
|
let queueView = normalizeReviewQueueView({ pageSize: options.pageSize });
|
|
1322
|
+
// The queue's items never change after mount (decisions live in events), so
|
|
1323
|
+
// the check against its extraction import runs once.
|
|
1324
|
+
const attestation = attestReviewQueueExtraction(baseSession.items, options.extractionImport);
|
|
1244
1325
|
const persistEvents = () => {
|
|
1245
1326
|
eventStore?.save(session, events);
|
|
1246
1327
|
};
|
|
@@ -1256,7 +1337,7 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1256
1337
|
const session = sessionForEvent;
|
|
1257
1338
|
const item = itemName ? session.items.find((entry) => entry.metadata.name === itemName) : undefined;
|
|
1258
1339
|
const decision = itemName ? session.decisionsByItemName[itemName] : undefined;
|
|
1259
|
-
const candidateId = item && decision ? decisionCandidateId(item, decision) : undefined;
|
|
1340
|
+
const candidateId = item && decision ? decisionCandidateId(item, decision, session.selectedCandidateIdsByItemName?.[item.metadata.name]) : undefined;
|
|
1260
1341
|
const definition = decision ? workbenchDecisionDefinitions[decision] : undefined;
|
|
1261
1342
|
const note = itemName ? session.notesByItemName[itemName] : undefined;
|
|
1262
1343
|
// Carry the reviewer's inline edit in the event (accept-proposed only), so
|
|
@@ -1299,7 +1380,7 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1299
1380
|
events = [...events, event];
|
|
1300
1381
|
persistEvents();
|
|
1301
1382
|
};
|
|
1302
|
-
const setDecision = (itemName, decision, rawEditedValue) => {
|
|
1383
|
+
const setDecision = (itemName, decision, rawEditedValue, selectedCandidateId) => {
|
|
1303
1384
|
if (decision === "could-not-confirm" && !session.notesByItemName[itemName]?.trim()) {
|
|
1304
1385
|
throw new Error("Could not confirm requires a non-empty reason.");
|
|
1305
1386
|
}
|
|
@@ -1318,11 +1399,19 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1318
1399
|
else {
|
|
1319
1400
|
delete nextEditedValuesByItemName[itemName];
|
|
1320
1401
|
}
|
|
1402
|
+
const nextSelections = { ...session.selectedCandidateIdsByItemName };
|
|
1403
|
+
if (decision === "select-proposed" && selectedCandidateId !== undefined) {
|
|
1404
|
+
nextSelections[itemName] = selectedCandidateId;
|
|
1405
|
+
}
|
|
1406
|
+
else {
|
|
1407
|
+
delete nextSelections[itemName];
|
|
1408
|
+
}
|
|
1321
1409
|
commitSessionUpdate({
|
|
1322
1410
|
...session,
|
|
1323
1411
|
activeItemName: itemName,
|
|
1324
1412
|
decisionsByItemName: { ...session.decisionsByItemName, [itemName]: decision },
|
|
1325
1413
|
editedValuesByItemName: nextEditedValuesByItemName,
|
|
1414
|
+
selectedCandidateIdsByItemName: nextSelections,
|
|
1326
1415
|
}, "decision-changed", itemName);
|
|
1327
1416
|
};
|
|
1328
1417
|
const clearDecision = (itemName) => {
|
|
@@ -1332,12 +1421,15 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1332
1421
|
delete remainingEdits[itemName];
|
|
1333
1422
|
const remainingAttemptEvidenceIds = { ...session.attemptEvidenceIdsByItemName };
|
|
1334
1423
|
delete remainingAttemptEvidenceIds[itemName];
|
|
1424
|
+
const remainingSelections = { ...session.selectedCandidateIdsByItemName };
|
|
1425
|
+
delete remainingSelections[itemName];
|
|
1335
1426
|
commitSessionUpdate({
|
|
1336
1427
|
...session,
|
|
1337
1428
|
activeItemName: itemName,
|
|
1338
1429
|
decisionsByItemName: remainingDecisions,
|
|
1339
1430
|
editedValuesByItemName: remainingEdits,
|
|
1340
1431
|
attemptEvidenceIdsByItemName: remainingAttemptEvidenceIds,
|
|
1432
|
+
selectedCandidateIdsByItemName: remainingSelections,
|
|
1341
1433
|
}, "decision-changed", itemName, { workbenchDecision: null });
|
|
1342
1434
|
};
|
|
1343
1435
|
const updateReviewerNote = (itemName, note) => {
|
|
@@ -1345,8 +1437,9 @@ function createReviewWorkbenchController(root, startState, options) {
|
|
|
1345
1437
|
};
|
|
1346
1438
|
const controller = {
|
|
1347
1439
|
currentSession: () => session,
|
|
1348
|
-
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
|
|
1349
|
-
|
|
1440
|
+
currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events, { presentationAdapter: options.presentationAdapter }),
|
|
1441
|
+
presentationAdapter: options.presentationAdapter,
|
|
1442
|
+
renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView, attestation),
|
|
1350
1443
|
setDecision,
|
|
1351
1444
|
clearDecision,
|
|
1352
1445
|
updateReviewerNote,
|
|
@@ -1366,8 +1459,8 @@ function reviewSessionStorageKey(session, keyPrefix) {
|
|
|
1366
1459
|
const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
|
|
1367
1460
|
return `${keyPrefix}:${itemNames}`;
|
|
1368
1461
|
}
|
|
1369
|
-
function renderCurrentState(root, session, controller, presentationAdapter, queueView) {
|
|
1370
|
-
root.innerHTML =
|
|
1462
|
+
function renderCurrentState(root, session, controller, presentationAdapter, queueView, attestation) {
|
|
1463
|
+
root.innerHTML = renderReviewQueueSessionHtml(session, presentationAdapter, queueView ?? normalizeReviewQueueView(undefined), attestation ?? attestReviewQueueExtraction(session.items, undefined));
|
|
1371
1464
|
bindFieldCardInteractions(root, controller);
|
|
1372
1465
|
bindApplyButton(root, controller);
|
|
1373
1466
|
bindClampToggles(root);
|
|
@@ -1415,6 +1508,9 @@ function queueSessionFromStartState(startState) {
|
|
|
1415
1508
|
...(startState.attemptEvidenceIds?.length
|
|
1416
1509
|
? { attemptEvidenceIdsByItemName: { [startState.item.metadata.name]: [...startState.attemptEvidenceIds] } }
|
|
1417
1510
|
: {}),
|
|
1511
|
+
...(startState.decision === "select-proposed" && startState.selectedCandidateId !== undefined
|
|
1512
|
+
? { selectedCandidateIdsByItemName: { [startState.item.metadata.name]: startState.selectedCandidateId } }
|
|
1513
|
+
: {}),
|
|
1418
1514
|
reviewedAt: startState.reviewedAt,
|
|
1419
1515
|
actorId: startState.actorId,
|
|
1420
1516
|
};
|
|
@@ -1472,6 +1568,21 @@ function bindFieldCardInteractions(root, controller) {
|
|
|
1472
1568
|
controller.renderCurrentState();
|
|
1473
1569
|
});
|
|
1474
1570
|
});
|
|
1571
|
+
// One value of a conflict: the button names its candidate, and the decision
|
|
1572
|
+
// records that id. The builder refuses an id that is not one of the item's
|
|
1573
|
+
// proposed candidates, so a stale or forged button changes nothing.
|
|
1574
|
+
root.querySelectorAll("[data-testid='select-value']").forEach((button) => {
|
|
1575
|
+
button.addEventListener("click", () => {
|
|
1576
|
+
const itemName = button.dataset.itemName ?? "";
|
|
1577
|
+
const candidateId = button.dataset.candidateId ?? "";
|
|
1578
|
+
const item = controller.currentSession().items.find((entry) => entry.metadata.name === itemName);
|
|
1579
|
+
if (!item || conflictSelectionIssue(item, candidateId)) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
controller.setDecision(itemName, "select-proposed", undefined, candidateId);
|
|
1583
|
+
controller.renderCurrentState();
|
|
1584
|
+
});
|
|
1585
|
+
});
|
|
1475
1586
|
root.querySelectorAll("[data-testid='keep-current']").forEach((button) => {
|
|
1476
1587
|
button.addEventListener("click", () => {
|
|
1477
1588
|
const itemName = button.dataset.itemName ?? "";
|
|
@@ -1582,7 +1693,7 @@ function refreshAuditPayloadForItem(textarea, controller, itemName) {
|
|
|
1582
1693
|
reviewedAt: session.reviewedAt,
|
|
1583
1694
|
actorId: session.actorId,
|
|
1584
1695
|
};
|
|
1585
|
-
payload.textContent = JSON.stringify(buildReviewDecision(state) ?? null, null, 2);
|
|
1696
|
+
payload.textContent = JSON.stringify(buildReviewDecision(state, { presentationAdapter: controller.presentationAdapter }) ?? null, null, 2);
|
|
1586
1697
|
}
|
|
1587
1698
|
function bindClampToggles(root) {
|
|
1588
1699
|
root.querySelectorAll("[data-clamp-toggle]").forEach((button) => {
|