@kontourai/survey 2.1.0 → 2.2.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/README.md CHANGED
@@ -212,7 +212,9 @@ The [Consumer Integration Guide](docs/consumer-integration-guide.md) covers the
212
212
  Portable extraction imports can also attach a source-linked, read-only inspector
213
213
  to the same workbench. It verifies resolved artifact identity before showing
214
214
  exact highlights, keeps unresolved material visibly non-grounded, and exports
215
- with source text and excerpts redacted by default. See
215
+ with source text and excerpts redacted by default. Review queues and inspector
216
+ candidates use bounded, searchable paging so large validated result sets remain
217
+ navigable without changing canonical review events or exports. See
216
218
  [Extraction Envelope Import](docs/extraction-envelope-import.md#source-linked-inspection).
217
219
 
218
220
 
@@ -65,11 +65,16 @@ export interface ExtractionInspectorFilters {
65
65
  pass?: string;
66
66
  inferenceType?: "explicit" | "inferred";
67
67
  alignment?: ExtractionAlignmentState;
68
+ query?: string;
68
69
  }
69
70
  export interface ExtractionInspectorExportOptions {
70
71
  includePreparedText?: boolean;
71
72
  includeExcerpts?: boolean;
72
73
  }
74
+ export interface ExtractionInspectorMountOptions {
75
+ /** Maximum candidate rows and source highlights mounted at once. Defaults to 100 and is capped at 500. */
76
+ pageSize?: number;
77
+ }
73
78
  /**
74
79
  * Build a read-only view from one or more results already produced by Survey's
75
80
  * public import boundary. The function rechecks the result/ReviewItem binding
@@ -78,4 +83,4 @@ export interface ExtractionInspectorExportOptions {
78
83
  export declare function buildExtractionInspectorModel(input: ExtractionInspectorInput): ExtractionInspectorModel;
79
84
  export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
80
85
  export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
81
- export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel): () => void;
86
+ export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel, options?: ExtractionInspectorMountOptions): () => void;
@@ -149,7 +149,16 @@ function resolvePdfPage(offsets, start) {
149
149
  return page;
150
150
  }
151
151
  export function filterExtractionInspectorCandidates(model, filters) {
152
- return model.candidates.filter((c) => (!filters.field || c.field === filters.field) && (!filters.provider || c.provider === filters.provider) && (!filters.model || c.model === filters.model) && (!filters.attempt || c.attempt === filters.attempt) && (!filters.pass || c.pass === filters.pass) && (!filters.inferenceType || c.inferenceType === filters.inferenceType) && (!filters.alignment || c.alignment === filters.alignment));
152
+ const query = filters.query?.trim().toLocaleLowerCase();
153
+ return model.candidates.filter((c) => (!filters.field || c.field === filters.field)
154
+ && (!filters.provider || c.provider === filters.provider)
155
+ && (!filters.model || c.model === filters.model)
156
+ && (!filters.attempt || c.attempt === filters.attempt)
157
+ && (!filters.pass || c.pass === filters.pass)
158
+ && (!filters.inferenceType || c.inferenceType === filters.inferenceType)
159
+ && (!filters.alignment || c.alignment === filters.alignment)
160
+ && (!query || [c.field, c.excerpt, c.reviewItemName, c.provider, c.model ?? "", c.pass ?? ""]
161
+ .some(value => value.toLocaleLowerCase().includes(query))));
153
162
  }
154
163
  export function exportExtractionInspector(model, options = {}) {
155
164
  return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
@@ -158,25 +167,45 @@ export function exportExtractionInspector(model, options = {}) {
158
167
  candidates: model.candidates.map((candidate) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
159
168
  } });
160
169
  }
161
- export function mountExtractionInspector(container, model) {
170
+ export function mountExtractionInspector(container, model, options = {}) {
171
+ const pageSize = Number.isSafeInteger(options.pageSize) && Number(options.pageSize) > 0
172
+ ? Math.min(Number(options.pageSize), 500)
173
+ : 100;
174
+ let page = 0;
162
175
  const root = document.createElement("section");
163
176
  root.className = "extraction-inspector";
164
177
  root.setAttribute("aria-label", "Source-linked extraction inspector");
165
- const options = (values) => [...new Set(values.filter((v) => Boolean(v)))].map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
166
- root.innerHTML = `<div class="inspector-heading"><div><p class="eyebrow">Source inspector</p><h2>Extraction evidence</h2></div><div class="inspector-postures" aria-live="polite"></div></div><div class="inspector-filters" aria-label="Extraction filters">${filterSelect("field", "Field", options(model.candidates.map(c => c.field)))}${filterSelect("provider", "Provider", options(model.candidates.map(c => c.provider)))}${filterSelect("model", "Model", options(model.candidates.map(c => c.model)))}${filterSelect("attempt", "Attempt", options(model.candidates.map(c => c.attempt)))}${filterSelect("pass", "Pass", options(model.candidates.map(c => c.pass)))}${filterSelect("inferenceType", "Type origin", '<option value="explicit">explicit</option><option value="inferred">inferred</option>')}${filterSelect("alignment", "Alignment", options(model.candidates.map(c => c.alignment)))}</div><div class="inspector-layout"><ol class="inspector-candidates" aria-label="Extraction candidates"></ol><div class="inspector-sources"></div></div>`;
178
+ const choiceOptions = (values) => [...new Set(values.filter((v) => Boolean(v)))].map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
179
+ root.innerHTML = `<div class="inspector-heading"><div><p class="eyebrow">Source inspector</p><h2>Extraction evidence</h2></div><div class="inspector-postures" aria-live="polite"></div></div><div class="inspector-filters" aria-label="Extraction filters"><label>Find candidates<input type="search" data-filter="query" placeholder="Field or excerpt"></label>${filterSelect("field", "Field", choiceOptions(model.candidates.map(c => c.field)))}${filterSelect("provider", "Provider", choiceOptions(model.candidates.map(c => c.provider)))}${filterSelect("model", "Model", choiceOptions(model.candidates.map(c => c.model)))}${filterSelect("attempt", "Attempt", choiceOptions(model.candidates.map(c => c.attempt)))}${filterSelect("pass", "Pass", choiceOptions(model.candidates.map(c => c.pass)))}${filterSelect("inferenceType", "Type origin", '<option value="explicit">explicit</option><option value="inferred">inferred</option>')}${filterSelect("alignment", "Alignment", choiceOptions(model.candidates.map(c => c.alignment)))}</div><nav class="inspector-pager" aria-label="Extraction candidate navigation"><span class="inspector-result-count" aria-live="polite"></span><button type="button" data-page="previous">Previous</button><span class="inspector-page"></span><button type="button" data-page="next">Next</button></nav><div class="inspector-layout"><ol class="inspector-candidates" aria-label="Extraction candidates"></ol><div class="inspector-sources"></div></div>`;
167
180
  container.appendChild(root);
168
181
  const list = root.querySelector("ol"), sourcesRoot = root.querySelector(".inspector-sources"), postures = root.querySelector(".inspector-postures");
182
+ const resultCount = root.querySelector(".inspector-result-count"), pageLabel = root.querySelector(".inspector-page");
183
+ const previous = root.querySelector('[data-page="previous"]'), next = root.querySelector('[data-page="next"]');
169
184
  const filters = {};
170
185
  const render = () => {
171
- const visible = filterExtractionInspectorCandidates(model, filters);
186
+ const matching = filterExtractionInspectorCandidates(model, filters);
187
+ const pageCount = Math.max(1, Math.ceil(matching.length / pageSize));
188
+ page = Math.min(page, pageCount - 1);
189
+ const start = page * pageSize;
190
+ const visible = matching.slice(start, start + pageSize);
172
191
  list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="candidate-${safeId(c.id)}" data-candidate-id="${escapeHtml(c.id)}" aria-controls="highlight-${safeId(c.id)}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span>${formatContext(c)}</button></li>`).join("") || "<li>No candidates match these filters.</li>";
192
+ resultCount.textContent = matching.length === 0 ? "No matching candidates" : `${start + 1}–${Math.min(start + pageSize, matching.length)} of ${matching.length}`;
193
+ pageLabel.textContent = `Page ${page + 1} of ${pageCount}`;
194
+ previous.disabled = page === 0;
195
+ next.disabled = page >= pageCount - 1;
173
196
  postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
174
197
  sourcesRoot.innerHTML = model.sources.map(s => { const candidates = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, candidates)}</pre></div>`; }).join("");
175
198
  };
176
199
  root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
177
200
  filters[key] = select.value;
178
201
  else
179
- delete filters[key]; render(); }));
202
+ delete filters[key]; page = 0; render(); }));
203
+ root.querySelector('input[data-filter="query"]')?.addEventListener("input", event => { event.stopPropagation(); const input = event.currentTarget; if (input.value)
204
+ filters.query = input.value;
205
+ else
206
+ delete filters.query; page = 0; render(); });
207
+ previous.addEventListener("click", () => { page = Math.max(0, page - 1); render(); list.querySelector("button")?.focus(); });
208
+ next.addEventListener("click", () => { page += 1; render(); list.querySelector("button")?.focus(); });
180
209
  const activateCandidate = (id) => { const candidate = model.candidates.find(c => c.id === id); if (!candidate)
181
210
  return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: id, reviewItemName: candidate.reviewItemName } })); };
182
211
  root.addEventListener("click", event => { const candidate = event.target.closest("button[data-candidate-id]"); if (candidate) {
@@ -189,10 +218,17 @@ export function mountExtractionInspector(container, model) {
189
218
  event.stopPropagation();
190
219
  root.querySelector(`#candidate-${CSS.escape(safeId(highlight.dataset.highlightCandidateId))}`)?.focus();
191
220
  } });
192
- root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (button && (event.key === "Enter" || event.key === " ")) {
221
+ root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (!button)
222
+ return; if (event.key === "Enter" || event.key === " ") {
193
223
  event.preventDefault();
194
224
  event.stopPropagation();
195
225
  activateCandidate(button.dataset.candidateId);
226
+ return;
227
+ } if (event.key === "ArrowDown" || event.key === "ArrowUp") {
228
+ event.preventDefault();
229
+ const buttons = [...list.querySelectorAll("button[data-candidate-id]")];
230
+ const index = buttons.indexOf(button);
231
+ buttons[event.key === "ArrowDown" ? Math.min(index + 1, buttons.length - 1) : Math.max(index - 1, 0)]?.focus();
196
232
  } });
197
233
  render();
198
234
  return () => root.remove();
@@ -195,7 +195,11 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
195
195
  .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
196
196
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
197
197
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
198
- .survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
198
+ .survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
199
+ .survey-workbench-embed .inspector-pager, .survey-workbench-embed .queue-controls, .survey-workbench-embed .queue-pager{ display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
200
+ .survey-workbench-embed .inspector-pager{ justify-content: flex-end; margin: 0 0 .75rem; }
201
+ .survey-workbench-embed .inspector-pager button, .survey-workbench-embed .queue-pager button{ color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
202
+ .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
199
203
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
200
204
  .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
201
205
  .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
@@ -206,6 +210,12 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
206
210
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
207
211
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
208
212
 
213
+ .survey-workbench-embed .queue-controls{ padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
214
+ .survey-workbench-embed .queue-controls label{ display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
215
+ .survey-workbench-embed .queue-controls input, .survey-workbench-embed .queue-controls select{ min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
216
+ .survey-workbench-embed .queue-result-count{ margin-left: auto; color: var(--k-muted); }
217
+ .survey-workbench-embed .queue-empty{ padding: 2rem; color: var(--k-muted); text-align: center; }
218
+
209
219
  /* Re-assert the base structural tokens for light mode. themes.css's \`.theme-*\`
210
220
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
211
221
  works" without requiring [data-theme] to be set), and a bare class selector
@@ -192,7 +192,11 @@
192
192
  .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
193
193
  .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
194
194
  .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
195
- .survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
195
+ .survey-workbench-embed .inspector-filters select, .survey-workbench-embed .inspector-filters input{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
196
+ .survey-workbench-embed .inspector-pager, .survey-workbench-embed .queue-controls, .survey-workbench-embed .queue-pager{ display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
197
+ .survey-workbench-embed .inspector-pager{ justify-content: flex-end; margin: 0 0 .75rem; }
198
+ .survey-workbench-embed .inspector-pager button, .survey-workbench-embed .queue-pager button{ color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
199
+ .survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
196
200
  .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
197
201
  .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
198
202
  .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
@@ -203,6 +207,12 @@
203
207
  .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
204
208
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
205
209
 
210
+ .survey-workbench-embed .queue-controls{ padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
211
+ .survey-workbench-embed .queue-controls label{ display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
212
+ .survey-workbench-embed .queue-controls input, .survey-workbench-embed .queue-controls select{ min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
213
+ .survey-workbench-embed .queue-result-count{ margin-left: auto; color: var(--k-muted); }
214
+ .survey-workbench-embed .queue-empty{ padding: 2rem; color: var(--k-muted); text-align: center; }
215
+
206
216
  /* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
207
217
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
208
218
  works" without requiring [data-theme] to be set), and a bare class selector
@@ -2,7 +2,7 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
2
2
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
5
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
6
6
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
7
7
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
8
8
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
@@ -83,6 +83,23 @@ export interface PersistentReviewSessionEventStoreOptions {
83
83
  export interface MountReviewWorkbenchOptions {
84
84
  readonly eventStore?: ReviewSessionEventStore;
85
85
  readonly presentationAdapter?: ReviewPresentationAdapter;
86
+ /** Maximum review cards mounted at once. Defaults to 50 and is capped at 250. */
87
+ readonly pageSize?: number;
88
+ }
89
+ export type ReviewQueueDisposition = "all" | "unresolved" | "accepted" | "kept" | "rejected" | "could-not-confirm";
90
+ export interface ReviewQueueView {
91
+ readonly query: string;
92
+ readonly disposition: ReviewQueueDisposition;
93
+ readonly page: number;
94
+ readonly pageSize: number;
95
+ }
96
+ export interface ReviewQueueWindow {
97
+ readonly items: readonly ReviewItem[];
98
+ readonly totalMatching: number;
99
+ readonly page: number;
100
+ readonly pageCount: number;
101
+ readonly start: number;
102
+ readonly end: number;
86
103
  }
87
104
  export interface BrowserReviewWorkbenchConfig {
88
105
  readonly startState?: ReviewQueueSessionState | ReviewWorkbenchState;
@@ -179,7 +196,9 @@ export declare function createPersistentReviewSessionEventStore(options: Persist
179
196
  };
180
197
  export declare function renderReviewWorkbenchHtml(state: ReviewWorkbenchState | ReviewQueueSessionState, _events?: readonly ReviewSessionEvent[], options?: {
181
198
  readonly presentationAdapter?: ReviewPresentationAdapter;
199
+ readonly queueView?: Partial<ReviewQueueView>;
182
200
  }): string;
201
+ export declare function buildReviewQueueWindow(session: ReviewQueueSessionState, view?: Partial<ReviewQueueView>): ReviewQueueWindow;
183
202
  /**
184
203
  * Validates a reviewer's raw (string) edit of a proposed value against the
185
204
  * field's neutral {@link ReviewValueDescriptor}, returning a human-readable
@@ -455,7 +455,7 @@ export function createPersistentReviewSessionEventStore(options) {
455
455
  }
456
456
  export function renderReviewWorkbenchHtml(state, _events, options = {}) {
457
457
  const session = queueSessionFromStartState(state);
458
- return renderReviewQueueSessionHtml(session, options.presentationAdapter);
458
+ return renderReviewQueueSessionHtml(session, options.presentationAdapter, normalizeReviewQueueView(options.queueView));
459
459
  }
460
460
  /**
461
461
  * The visual/decision state of a field card, derived from the reviewer's local
@@ -484,12 +484,56 @@ function chipLabel(state) {
484
484
  default: return "Needs review";
485
485
  }
486
486
  }
487
+ const DEFAULT_REVIEW_QUEUE_PAGE_SIZE = 50;
488
+ function normalizeReviewQueueView(view) {
489
+ const pageSize = Number.isSafeInteger(view?.pageSize) && Number(view?.pageSize) > 0
490
+ ? Math.min(Number(view?.pageSize), 250)
491
+ : DEFAULT_REVIEW_QUEUE_PAGE_SIZE;
492
+ return {
493
+ query: view?.query?.trim() ?? "",
494
+ disposition: view?.disposition ?? "all",
495
+ page: Number.isSafeInteger(view?.page) && Number(view?.page) >= 0 ? Number(view?.page) : 0,
496
+ pageSize,
497
+ };
498
+ }
499
+ export function buildReviewQueueWindow(session, view = {}) {
500
+ const normalized = normalizeReviewQueueView(view);
501
+ const query = normalized.query.toLocaleLowerCase();
502
+ const matching = session.items.filter((item) => {
503
+ const state = fieldCardState(item, session.decisionsByItemName[item.metadata.name]);
504
+ const dispositionMatches = normalized.disposition === "all"
505
+ || (normalized.disposition === "unresolved" ? state === "review" : state === normalized.disposition);
506
+ if (!dispositionMatches)
507
+ return false;
508
+ if (!query)
509
+ return true;
510
+ const producerDisplayName = item.metadata.producer?.displayName;
511
+ return [
512
+ item.metadata.name,
513
+ item.spec.target,
514
+ typeof producerDisplayName === "string" ? producerDisplayName : "",
515
+ ...item.spec.candidates.map((candidate) => formatValue(candidate.value)),
516
+ ].some((value) => value.toLocaleLowerCase().includes(query));
517
+ });
518
+ const pageCount = Math.max(1, Math.ceil(matching.length / normalized.pageSize));
519
+ const page = Math.min(normalized.page, pageCount - 1);
520
+ const start = page * normalized.pageSize;
521
+ const end = Math.min(start + normalized.pageSize, matching.length);
522
+ return {
523
+ items: matching.slice(start, end),
524
+ totalMatching: matching.length,
525
+ page,
526
+ pageCount,
527
+ start,
528
+ end,
529
+ };
530
+ }
487
531
  function isEmptyValue(value) {
488
532
  return value === undefined || value === null || value === "";
489
533
  }
490
534
  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>";
491
535
  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>";
492
- function renderReviewQueueSessionHtml(session, presentationAdapter) {
536
+ function renderReviewQueueSessionHtml(session, presentationAdapter, queueView) {
493
537
  const totalCount = session.items.length;
494
538
  const decidedCount = session.items.filter((item) => session.decisionsByItemName[item.metadata.name] !== undefined
495
539
  || item.spec.candidateSetStatus === "resolved").length;
@@ -497,6 +541,7 @@ function renderReviewQueueSessionHtml(session, presentationAdapter) {
497
541
  const headerItem = session.items[0];
498
542
  const producerName = headerItem?.metadata.producer?.displayName;
499
543
  const headerTitle = typeof producerName === "string" && producerName.length > 0 ? producerName : "Review queue";
544
+ const window = buildReviewQueueWindow(session, queueView);
500
545
  return `
501
546
  <section class="workbench-shell review" data-testid="review-workbench-shell" aria-label="Survey review workbench">
502
547
  <header class="rhead">
@@ -515,13 +560,43 @@ function renderReviewQueueSessionHtml(session, presentationAdapter) {
515
560
  <button class="apply" type="button" data-testid="apply-button"${decidedCount === 0 ? " disabled" : ""}>Apply ${decidedCount} decision${decidedCount === 1 ? "" : "s"}</button>
516
561
  </div>
517
562
  </header>
563
+ <nav class="queue-controls" aria-label="Review queue navigation">
564
+ <label>
565
+ <span>Find fields</span>
566
+ <input type="search" data-testid="queue-search" value="${escapeHtml(queueView.query)}" placeholder="Field or value">
567
+ </label>
568
+ <label>
569
+ <span>Disposition</span>
570
+ <select data-testid="queue-disposition">${queueDispositionOptions(queueView.disposition)}</select>
571
+ </label>
572
+ <span class="queue-result-count" aria-live="polite">
573
+ ${window.totalMatching === 0 ? "No matching fields" : `${window.start + 1}–${window.end} of ${window.totalMatching}`}
574
+ </span>
575
+ <div class="queue-pager">
576
+ <button type="button" data-testid="queue-previous"${window.page === 0 ? " disabled" : ""}>Previous</button>
577
+ <span>Page ${window.page + 1} of ${window.pageCount}</span>
578
+ <button type="button" data-testid="queue-next"${window.page >= window.pageCount - 1 ? " disabled" : ""}>Next</button>
579
+ </div>
580
+ </nav>
518
581
  <div class="fields" data-testid="review-fields">
519
- ${session.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")}
582
+ ${window.items.map((item) => renderFieldCard(item, session, presentationAdapter)).join("")
583
+ || '<p class="queue-empty">No review fields match the current search and disposition.</p>'}
520
584
  </div>
521
585
  ${renderFooterTally(session)}
522
586
  </section>
523
587
  `;
524
588
  }
589
+ function queueDispositionOptions(selected) {
590
+ const options = [
591
+ ["all", "All fields"],
592
+ ["unresolved", "Needs review"],
593
+ ["accepted", "Accepted"],
594
+ ["kept", "Kept current"],
595
+ ["rejected", "Flagged wrong"],
596
+ ["could-not-confirm", "Could not confirm"],
597
+ ];
598
+ return options.map(([value, label]) => `<option value="${value}"${selected === value ? " selected" : ""}>${label}</option>`).join("");
599
+ }
525
600
  function renderFieldCard(item, session, presentationAdapter) {
526
601
  const decision = session.decisionsByItemName[item.metadata.name];
527
602
  const state = fieldCardState(item, decision);
@@ -991,6 +1066,7 @@ function createReviewWorkbenchController(root, startState, options) {
991
1066
  const eventStore = options.eventStore ?? browserReviewSessionEventStore();
992
1067
  let events = eventStore?.load(baseSession) ?? [];
993
1068
  let session = events.length > 0 ? replayReviewSessionEvents(baseSession, events) : baseSession;
1069
+ let queueView = normalizeReviewQueueView({ pageSize: options.pageSize });
994
1070
  const persistEvents = () => {
995
1071
  eventStore?.save(session, events);
996
1072
  };
@@ -1094,10 +1170,13 @@ function createReviewWorkbenchController(root, startState, options) {
1094
1170
  const controller = {
1095
1171
  currentSession: () => session,
1096
1172
  currentSessionExport: () => buildReviewWorkbenchSessionExport(session, events),
1097
- renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter),
1173
+ renderCurrentState: () => renderCurrentState(root, session, controller, options.presentationAdapter, queueView),
1098
1174
  setDecision,
1099
1175
  clearDecision,
1100
1176
  updateReviewerNote,
1177
+ updateQueueView: (update) => {
1178
+ queueView = normalizeReviewQueueView({ ...queueView, ...update });
1179
+ },
1101
1180
  };
1102
1181
  return controller;
1103
1182
  }
@@ -1111,12 +1190,39 @@ function reviewSessionStorageKey(session, keyPrefix) {
1111
1190
  const itemNames = buildReviewSessionResource(session).spec.reviewItemNames.join(",");
1112
1191
  return `${keyPrefix}:${itemNames}`;
1113
1192
  }
1114
- function renderCurrentState(root, session, controller, presentationAdapter) {
1115
- root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter });
1193
+ function renderCurrentState(root, session, controller, presentationAdapter, queueView) {
1194
+ root.innerHTML = renderReviewWorkbenchHtml(session, undefined, { presentationAdapter, queueView });
1116
1195
  bindFieldCardInteractions(root, controller);
1117
1196
  bindApplyButton(root, controller);
1118
1197
  bindClampToggles(root);
1119
1198
  bindHistoryExpanders(root);
1199
+ bindQueueControls(root, controller, queueView ?? normalizeReviewQueueView(undefined));
1200
+ }
1201
+ function bindQueueControls(root, controller, queueView) {
1202
+ const search = root.querySelector('[data-testid="queue-search"]');
1203
+ search?.addEventListener("input", () => {
1204
+ const query = search.value;
1205
+ controller.updateQueueView({ query, page: 0 });
1206
+ controller.renderCurrentState();
1207
+ const nextSearch = root.querySelector('[data-testid="queue-search"]');
1208
+ nextSearch?.focus();
1209
+ nextSearch?.setSelectionRange(query.length, query.length);
1210
+ });
1211
+ root.querySelector('[data-testid="queue-disposition"]')?.addEventListener("change", (event) => {
1212
+ controller.updateQueueView({
1213
+ disposition: event.currentTarget.value,
1214
+ page: 0,
1215
+ });
1216
+ controller.renderCurrentState();
1217
+ });
1218
+ root.querySelector('[data-testid="queue-previous"]')?.addEventListener("click", () => {
1219
+ controller.updateQueueView({ page: Math.max(0, queueView.page - 1) });
1220
+ controller.renderCurrentState();
1221
+ });
1222
+ root.querySelector('[data-testid="queue-next"]')?.addEventListener("click", () => {
1223
+ controller.updateQueueView({ page: queueView.page + 1 });
1224
+ controller.renderCurrentState();
1225
+ });
1120
1226
  }
1121
1227
  function queueSessionFromStartState(startState) {
1122
1228
  if ("items" in startState) {
@@ -41,7 +41,11 @@
41
41
  .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
42
42
  .inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
43
43
  .inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
44
- .inspector-filters select { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
44
+ .inspector-filters select, .inspector-filters input { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
45
+ .inspector-pager, .queue-controls, .queue-pager { display: flex; align-items: end; flex-wrap: wrap; gap: .6rem; }
46
+ .inspector-pager { justify-content: flex-end; margin: 0 0 .75rem; }
47
+ .inspector-pager button, .queue-pager button { color: var(--k-text); background: var(--k-raised); border: 1px solid var(--k-line-strong); border-radius: var(--k-radius-sm); padding: .45rem .7rem; }
48
+ .inspector-pager button:disabled, .queue-pager button:disabled { opacity: .45; }
45
49
  .inspector-candidates { margin: 0; padding-left: 1.5rem; }
46
50
  .inspector-candidate { width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
47
51
  .inspector-source pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
@@ -52,6 +56,12 @@
52
56
  .source-unavailable { color: var(--k-negative); font-weight: 700; }
53
57
  @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
54
58
 
59
+ .queue-controls { padding: 1rem; border-bottom: 1px solid var(--k-line); background: var(--k-panel); }
60
+ .queue-controls label { display: grid; gap: .25rem; color: var(--k-muted); font-size: .75rem; }
61
+ .queue-controls input, .queue-controls select { min-height: 2.25rem; color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); border-radius: var(--k-radius-sm); padding: .4rem .55rem; }
62
+ .queue-result-count { margin-left: auto; color: var(--k-muted); }
63
+ .queue-empty { padding: 2rem; color: var(--k-muted); text-align: center; }
64
+
55
65
  /* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
56
66
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
57
67
  works" without requiring [data-theme] to be set), and a bare class selector
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",