filegrc 0.5.0 → 0.6.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/src/web.js CHANGED
@@ -11,6 +11,21 @@ import {
11
11
  import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS, RESOURCE_PAGE_SUMMARIES } from "./program-path.js";
12
12
  import { formatCalendarDate, formatLocalDateTime } from "./time.js";
13
13
 
14
+ export function dashboardProgramReadiness(programReadiness = {}) {
15
+ const progress = programReadiness.progress || {};
16
+ const lifecycle = programReadiness.operating
17
+ ? { status: "Operating", tone: "good" }
18
+ : programReadiness.evidenceReady
19
+ ? { status: "Evidence ready", tone: "good" }
20
+ : { status: "Needs work", tone: "warn" };
21
+ return {
22
+ percent: progress.percent ?? 0,
23
+ complete: progress.complete ?? 0,
24
+ total: progress.total ?? 0,
25
+ ...lifecycle
26
+ };
27
+ }
28
+
14
29
  export function renderIndex(state = null) {
15
30
  const snapshot = state
16
31
  ? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
@@ -51,6 +66,7 @@ const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
51
66
  number: String(stage.number)
52
67
  }));
53
68
  const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
69
+ const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
54
70
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
55
71
  ...RESOURCE_PAGE_SUMMARIES,
56
72
  "utility:audit-packet": "Review fieldwork readiness and build the indexed evidence packet."
@@ -65,12 +81,15 @@ let onboardingShade = null;
65
81
  let onboardingStep = 0;
66
82
  let onboardingDraft = null;
67
83
  let onboardingBusy = false;
84
+ let onboardingSetupOnly = false;
68
85
  let onboardingStillWorkingTimer = null;
69
86
  let onboardingPendingDraft = false;
70
87
  const resourceDetailRequests = new Map();
71
88
  let resourceGuideCleanup = null;
72
89
  let repositorySyncPollTimer = null;
73
90
  let repositorySyncPollInFlight = false;
91
+ let mutationStateRefreshInFlight = false;
92
+ let mutationStateRefreshTimer = null;
74
93
 
75
94
  start().catch((error) => {
76
95
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -85,7 +104,7 @@ async function start() {
85
104
  window.addEventListener("scroll", positionCurrentOnboarding, true);
86
105
  render();
87
106
  scheduleRepositorySyncPoll();
88
- if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true) {
107
+ if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
89
108
  queueMicrotask(requestOnboarding);
90
109
  }
91
110
  }
@@ -191,6 +210,16 @@ function readinessStageForType(type) {
191
210
  ));
192
211
  }
193
212
 
213
+ function activeProgram() {
214
+ return resourcesOfType("program").find(({ record }) => !["retired"].includes(record.status))?.record || state.workspace;
215
+ }
216
+
217
+ function reviewedRequirementIds() {
218
+ return new Set((activeProgram().requirementApplicability || [])
219
+ .filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
220
+ .map(({ requirementId }) => requirementId));
221
+ }
222
+
194
223
  function renderSidebarUtility(utility, route, direct = false) {
195
224
  const directClass = direct ? "nav-direct " : "";
196
225
  if (utility === "obligation-board") return "";
@@ -256,31 +285,32 @@ function renderHome(main) {
256
285
  '<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
257
286
  '<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(orderedPolicyEventTriggers(state.obligations.triggers).slice(0, 4)) + '</section>' +
258
287
  auditPanel + '</div></div>';
259
- main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
288
+ main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: Boolean(initialSetupSystem()) }));
289
+ }
290
+
291
+ function initialSetupSystem() {
292
+ const program = activeProgram();
293
+ return resourcesOfType("system").find(({ record }) => (program.systemIds || []).includes(record.id) && record.status !== "retired")?.record || null;
260
294
  }
261
295
 
262
296
  function initialSetupBanner() {
263
- const system = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired")?.record;
297
+ const program = activeProgram();
298
+ const system = initialSetupSystem();
264
299
  if (!system) {
265
- return '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
300
+ return '<section class="setup-banner"><div><p class="kicker">Setup</p><h3>Define the service in scope</h3><p>Name the service and choose the program goal.</p></div><button class="button primary" type="button" id="resume-setup">Start setup</button></section>';
266
301
  }
267
- const goal = programGoalFromKind(state.workspace.assuranceGoal);
302
+ const goal = programGoalFromKind(program.assuranceGoal);
268
303
  const goalLabels = {
269
304
  readiness: "Program Readiness",
270
305
  "type-1": "SOC 2 Type 1",
271
306
  "type-2": "SOC 2 Type 2"
272
307
  };
273
- const goalStep = goal === "none"
274
- ? "Choose the program goal."
275
- : "Confirm the saved program goal: " + goalLabels[goal] + ".";
276
- const completion = system.status === "planned"
277
- ? "Confirm the service scope to activate the planned service and continue to Step 1."
278
- : "Confirm the service scope to close onboarding and continue to Step 1.";
279
- return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review the initial service scope</h3><p>' + esc(system.title) + ' already has a saved service boundary.</p></div><ol><li>Review the saved service boundary.</li><li>' + esc(goalStep) + '</li><li>' + esc(completion) + '</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
308
+ const goalLabel = goal === "none" ? "Choose goal" : goalLabels[goal];
309
+ return '<section class="setup-banner"><div><p class="kicker">Setup draft</p><h3>' + esc(system.title) + '</h3><p>Review and confirm the initial scope.</p></div><div class="setup-draft-state"><span><small>Program goal</small><strong>' + esc(goalLabel) + '</strong></span><span><small>System</small><strong>' + esc(properCase(system.status)) + '</strong></span><button class="button primary" type="button" id="resume-setup">Resume setup</button></div></section>';
280
310
  }
281
311
 
282
312
  function readinessOverview() {
283
- const progress = programPathProgress();
313
+ const progress = dashboardProgramReadiness(state.programReadiness);
284
314
  const nextHref = nextProgramStageHref();
285
315
  const programStage = (id, href) => {
286
316
  const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
@@ -296,7 +326,7 @@ function readinessOverview() {
296
326
  programStage("run", "#/stage/run"),
297
327
  programStage("audit", "#/stage/audit")
298
328
  ];
299
- return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Program readiness</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program pages " + (progress.complete === 1 ? "is" : "are") + " ready") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
329
+ return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Program readiness</span><strong>' + progress.percent + '%</strong><b class="badge ' + esc(progress.tone) + '">' + esc(progress.status) + '</b><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " readiness items complete") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
300
330
  }
301
331
 
302
332
  function nextProgramStageHref() {
@@ -316,6 +346,10 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
316
346
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
317
347
  '<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 5</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
318
348
  renderStagePageIndex(stage) + (stage.id === "controls" ? renderEvidenceReadiness() : "") + '</div>';
349
+ main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
350
+ main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
351
+ event.currentTarget.remove();
352
+ });
319
353
  }
320
354
 
321
355
  function workflowGuidance(options = {}) {
@@ -354,20 +388,54 @@ function collectionReviewPanel(type) {
354
388
  if (!assessment) return "";
355
389
  const configuration = assessment.configuration;
356
390
  const current = assessment.status === "current";
391
+ const needsFirstRecord = collectionNeedsFirstRecord(type);
357
392
  const reviewerNames = (assessment.review?.reviewedByIds || [])
358
393
  .map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
394
+ const reviewNote = assessment.review?.rationale
395
+ ? " Note: " + esc(assessment.review.rationale)
396
+ : "";
359
397
  const reviewSummary = current
360
- ? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + '.</span></p>'
361
- : '<p class="collection-review-result"><strong>' + (assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
362
- return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '"><div class="collection-review-head"><div><p class="kicker">Scope confirmation</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + (current ? "Reviewed" : assessment.status === "stale" ? "Stale" : "Review required") + '</span></div>' +
398
+ ? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
399
+ : '<p class="collection-review-result"><strong>' + (needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
400
+ const action = state.readOnly
401
+ ? ""
402
+ : needsFirstRecord
403
+ ? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
404
+ : '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>';
405
+ return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '"><div class="collection-review-head"><div><p class="kicker">Scope confirmation</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + (current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required") + '</span></div>' +
363
406
  '<details ' + (current ? "" : "open") + '><summary>What to review</summary><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></details>' +
364
- '<div class="collection-review-foot">' + reviewSummary + (!state.readOnly ? '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? "Review again" : "Review and confirm") + '</button>' : "") + '</div></section>';
407
+ '<div class="collection-review-foot">' + reviewSummary + action + '</div></section>';
408
+ }
409
+
410
+ function collectionNeedsFirstRecord(type) {
411
+ const assessment = state.collectionReviews?.[type];
412
+ if (!assessment || assessment.recordCount) return false;
413
+ return !(assessment.configuration.decisions || []).some((decision) => (
414
+ decision === "zero-population" || decision === "externally-managed"
415
+ ));
416
+ }
417
+
418
+ function resourceCreationAllowed(type) {
419
+ return type !== "program" || !resourcesOfType("program").some(({ record }) => record.status !== "retired");
365
420
  }
366
421
 
367
- function resourceReviewCriteria(type) {
422
+ function collectionEmptyState(type, definition) {
423
+ const assessment = state.collectionReviews?.[type];
424
+ if (!assessment) {
425
+ return definition.guidance?.emptyState || "No records exist. Use the page guidance to decide whether a record is required, then add only real program facts.";
426
+ }
427
+ return collectionNeedsFirstRecord(type)
428
+ ? "No " + definition.pluralTitle.toLowerCase() + " have been added yet."
429
+ : "No records exist. Use the scope confirmation above to record an allowed empty-collection conclusion, or add the records management identified.";
430
+ }
431
+
432
+ function resourceReviewCriteria(type, collapsed = false) {
368
433
  const reviewPoints = state.model.resources[type]?.guidance?.reviewPoints || [];
369
434
  if (!reviewPoints.length) return "";
370
- return '<section class="resource-review-criteria"><strong>What the reviewer should check</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>';
435
+ const content = '<strong>Review criteria</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>';
436
+ return collapsed
437
+ ? '<details class="resource-review-criteria compact"><summary>Review criteria</summary><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></details>'
438
+ : '<section class="resource-review-criteria">' + content + '</section>';
371
439
  }
372
440
 
373
441
  function recordWorkflowItems(type, id) {
@@ -393,7 +461,8 @@ function recordWorkflowCell(type, entry) {
393
461
  if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
394
462
  const item = items[0];
395
463
  const href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
396
- return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message || workflowItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
464
+ const title = type === "control" ? "Finish implementation" : item.title;
465
+ return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(title) + '</strong><small>' + esc(stagePageItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
397
466
  }
398
467
 
399
468
  function openCollectionReviewDialog(type) {
@@ -401,7 +470,9 @@ function openCollectionReviewDialog(type) {
401
470
  if (!assessment) return;
402
471
  const configuration = assessment.configuration;
403
472
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
404
- const systems = resourcesOfType("system").filter(({ record }) => record.status === "active");
473
+ const v4 = String(state.model.modelVersion) === "4";
474
+ const sourceType = v4 ? "component" : "system";
475
+ const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
405
476
  const allowedDecisions = configuration.decisions || ["complete"];
406
477
  const defaultDecision = assessment.review?.decision
407
478
  || (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
@@ -411,15 +482,17 @@ function openCollectionReviewDialog(type) {
411
482
  const dialog = document.createElement("dialog");
412
483
  dialog.className = "commit-dialog event-dialog collection-review-dialog";
413
484
  dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
414
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(assessment.review?.reviewedOn || currentDate()) + '"></label><label data-authoritative-system><span>Authoritative System</span><select name="authoritativeSystemId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (assessment.review?.authoritativeSystemId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-collection-review>Preview confirmation</button></div></form>';
485
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(assessment.review?.reviewedOn || currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId) === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-collection-review>Preview confirmation</button></div></form>';
415
486
  document.body.append(dialog);
416
487
  dialog.showModal();
417
488
  const form = dialog.querySelector("form");
489
+ const saveStatus = dialog.querySelector(".review-save-status");
490
+ const repositoryPrefetch = prefetchRepositoryForReview(saveStatus);
418
491
  const systemField = dialog.querySelector("[data-authoritative-system]");
419
492
  const syncDecision = () => {
420
493
  const external = form.elements.decision.value === "externally-managed";
421
494
  systemField.hidden = !external;
422
- form.elements.authoritativeSystemId.required = external;
495
+ form.elements.authoritativeSourceId.required = external;
423
496
  };
424
497
  syncDecision();
425
498
  dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
@@ -443,7 +516,7 @@ function openCollectionReviewDialog(type) {
443
516
  rationale: form.elements.rationale.value.trim(),
444
517
  reviewedByIds: [form.elements.reviewerId.value],
445
518
  reviewedOn: form.elements.reviewedOn.value,
446
- authoritativeSystemId: form.elements.authoritativeSystemId.value || undefined,
519
+ [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
447
520
  expectedRevision: assessment.reviewRevision || undefined
448
521
  };
449
522
  form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
@@ -460,12 +533,15 @@ function openCollectionReviewDialog(type) {
460
533
  dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>Save this confirmation for ' + preview.assessment.recordCount + ' current ' + esc(pluralize("record", preview.assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p>';
461
534
  dialog.querySelector("[data-preview-collection-review]").textContent = "Confirm and save";
462
535
  } else {
536
+ saveStatus.textContent = "Validating and saving…";
537
+ const prefetch = await repositoryPrefetch;
463
538
  const response = await localFetch("/api/collection-review", {
464
539
  method: "POST",
465
- headers: { "content-type": "application/json" },
466
- body: JSON.stringify({ ...previewedPayload, confirmed: true })
540
+ headers: { "content-type": "application/json", prefer: "respond-async" },
541
+ body: JSON.stringify({ ...previewedPayload, confirmed: true, prefetchToken: prefetch?.token })
467
542
  });
468
543
  if (!response.ok) throw new Error(await responseMessage(response));
544
+ saveStatus.textContent = "Saved locally. Refreshing page…";
469
545
  applyMutationState(await response.json());
470
546
  dialog.close();
471
547
  render();
@@ -566,11 +642,18 @@ function renderEvidenceReadiness() {
566
642
  ?.find((stage) => stage.id === "controls")
567
643
  ?.items.filter((item) => item.id.startsWith("source-family-")) || [];
568
644
  const completeCount = items.filter((item) => item.status === "complete").length;
569
- const cards = items.map((item) => {
570
- const sources = (item.sourceSystemIds || []).map((id) => {
571
- const source = state.resources.find(({ record }) => record.type === "system" && record.id === id)?.record;
572
- const sourceCheck = (item.sourceSystemChecks || []).find(({ sourceSystemId }) => sourceSystemId === id);
573
- const complete = sourceCheck?.complete ?? (item.completeSourceSystemIds || []).includes(id);
645
+ const visibleCount = 4;
646
+ const componentSources = items.some((item) => Array.isArray(item.sourceComponentIds));
647
+ const sourceType = componentSources ? "component" : "system";
648
+ const sourceLabel = componentSources ? "Components" : "Systems";
649
+ const cards = items.map((item, index) => {
650
+ const componentSources = Array.isArray(item.sourceComponentIds);
651
+ const sourceType = componentSources ? "component" : "system";
652
+ const sourceLabel = componentSources ? "Component" : "System";
653
+ const sources = (item.sourceComponentIds || item.sourceSystemIds || []).map((id) => {
654
+ const source = state.resources.find(({ record }) => record.type === sourceType && record.id === id)?.record;
655
+ const sourceCheck = (item.sourceComponentChecks || item.sourceSystemChecks || []).find((check) => (check.sourceComponentId || check.sourceSystemId) === id);
656
+ const complete = sourceCheck?.complete ?? (item.completeSourceComponentIds || item.completeSourceSystemIds || []).includes(id);
574
657
  const status = complete
575
658
  ? "Ready"
576
659
  : Object.entries(sourceCheck?.checks || {})
@@ -578,23 +661,26 @@ function renderEvidenceReadiness() {
578
661
  .map(([name]) => evidenceSourceCheckLabel(name))
579
662
  .join(", ") || "Needs details";
580
663
  return source
581
- ? '<a class="evidence-map-source ' + (complete ? "complete" : "incomplete") + '" href="#/resource/system/' + encodeURIComponent(id) + '">' + esc(source.title) + '<small>' + esc(status) + '</small></a>'
664
+ ? '<a class="evidence-map-source ' + (complete ? "complete" : "incomplete") + '" href="#/resource/' + sourceType + '/' + encodeURIComponent(id) + '">' + esc(source.title) + '<small>' + esc(status) + '</small></a>'
582
665
  : "";
583
666
  }).join("");
584
667
  const sourceAction = sources
585
668
  ? sources
586
- : '<a class="button" href="#/resources/system?new=1">Add source System</a>';
669
+ : '<a class="button" href="#/resources/' + sourceType + '?new=1">Add source ' + sourceLabel + '</a>';
587
670
  const method = item.operationRecordTypes?.length
588
671
  ? "FileGRC records: " + item.operationRecordTypes.map(properCase).join(", ")
589
672
  : properCase(item.evidenceForm || "External evidence");
590
- return '<article class="evidence-map-card ' + esc(item.status) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (item.status === "complete" ? "good" : "warn") + '">' + (item.status === "complete" ? "Mapped" : "Needs mapping") + '</span><h3>' + esc(item.title) + '</h3></div><small>' + esc(method) + '</small></div><p>' + esc(item.description || item.message) + '</p>' +
591
- (item.sourceKinds?.length ? '<div class="evidence-map-expectation"><strong>Source role</strong><span>' + item.sourceKinds.map((kind) => '<code>' + esc(kind) + '</code>').join(" or ") + '</span></div>' : "") +
673
+ return '<article class="evidence-map-card ' + esc(item.status) + '"' + (index >= visibleCount ? ' data-evidence-family-extra hidden' : "") + '><div class="evidence-map-card-head"><div><span class="badge ' + (item.status === "complete" ? "good" : "warn") + '">' + (item.status === "complete" ? "Mapped" : "Needs mapping") + '</span><h3>' + esc(item.title) + '</h3></div><small>' + esc(method) + '</small></div><p>' + esc(item.description || item.message) + '</p>' +
674
+ (item.sourceKinds?.length ? '<div class="evidence-map-expectation"><strong>Source role</strong><span>' + item.sourceKinds.map((kind) => esc(properCase(kind))).join(" or ") + '</span></div>' : "") +
592
675
  (item.evidencePrompt ? '<div class="evidence-map-expectation"><strong>Expected evidence</strong><span>' + esc(item.evidencePrompt) + '</span></div>' : "") +
593
676
  (item.timing ? '<div class="evidence-map-expectation"><strong>When</strong><span>' + esc(item.timing) + '</span></div>' : "") +
594
677
  '<div class="evidence-map-links"><div><small>Controls</small><div class="evidence-map-references">' + (item.controlIds || []).map((id) => formatReference(id)).join("") + '</div></div><div><small>Authoritative sources</small><div class="evidence-map-sources">' + sourceAction + '</div></div></div><p class="evidence-map-status">' + esc(item.message) + '</p></article>';
595
678
  }).join("");
679
+ const more = items.length > visibleCount
680
+ ? '<button class="button evidence-map-more" type="button" data-show-evidence-families>Show ' + (items.length - visibleCount) + ' more evidence families</button>'
681
+ : "";
596
682
  const empty = '<section class="evidence-map-empty"><p class="kicker">Evidence readiness</p><h3>Select the program controls first</h3><p>Control implementation checks are generated from the selected Controls and their authoritative evidence sources.</p><a class="button primary" href="#/resources/control">Review Controls</a></section>';
597
- return '<section class="evidence-map"><div class="evidence-map-head"><div><p class="kicker">Control implementation</p><h2>' + completeCount + ' of ' + items.length + ' evidence ' + (items.length === 1 ? "family" : "families") + ' ready</h2><p>Connect each Control to the Systems that produce its evidence. The cards below show what each source still needs.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/system">Review Systems</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + '</section>';
683
+ return '<section class="evidence-map"><div class="evidence-map-head"><div><p class="kicker">Control implementation</p><h2>' + completeCount + ' of ' + items.length + ' evidence ' + (items.length === 1 ? "family" : "families") + ' ready</h2><p>Connect each Control to the ' + sourceLabel + ' that produce its evidence.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/' + sourceType + '">Review ' + sourceLabel + '</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + more + '</section>';
598
684
  }
599
685
 
600
686
  function evidenceSourceCheckLabel(name) {
@@ -647,6 +733,7 @@ function stagePageItemDetail(item) {
647
733
  ? String(item.message || "").match(/^Complete (\d+) checks before implementation:/)
648
734
  : null;
649
735
  if (controlChecks) return controlChecks[1] + " implementation checks remain. Open the Control to review them.";
736
+ if (/^Review .+ before this page can be ready\.$/i.test(item.message || "")) return "Confirm the current scope.";
650
737
  return item.message || workflowItemDetail(item);
651
738
  }
652
739
 
@@ -740,7 +827,7 @@ function stagePageItems(stage, destination) {
740
827
 
741
828
  function operationProgress() {
742
829
  const program = state.programReadiness;
743
- const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
830
+ const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
744
831
  const asOf = program?.asOf || currentDate();
745
832
  const candidateStarted = goal === "soc-2-type-2"
746
833
  ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
@@ -800,15 +887,6 @@ function operationProgress() {
800
887
  };
801
888
  }
802
889
 
803
- function programPathProgress() {
804
- const progress = READINESS_STAGES.map((stage) => stageProgress(stage));
805
- return progressFromCounts(
806
- progress.reduce((sum, current) => sum + current.complete, 0),
807
- progress.reduce((sum, current) => sum + current.total, 0),
808
- "program milestone"
809
- );
810
- }
811
-
812
890
  function progressFromCounts(complete, total, noun) {
813
891
  if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
814
892
  const percent = Math.round((complete / total) * 100);
@@ -830,7 +908,7 @@ function sectionDestinations(section) {
830
908
  destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
831
909
  }
832
910
  if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
833
- if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc and External Evidence, prepare fieldwork, and build the indexed packet." });
911
+ if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc Evidence and Evidence Artifacts, prepare fieldwork, and build the indexed packet." });
834
912
  return destinations;
835
913
  }
836
914
 
@@ -850,10 +928,10 @@ function renderExternalEvidenceSection() {
850
928
  )).join("");
851
929
  const createButton = state.readOnly
852
930
  ? ""
853
- : '<button class="button primary" type="button" data-new-external-evidence>New external evidence</button>';
854
- return '<section class="workflow-section external-evidence-section"><div class="section-head"><div><p class="kicker">Fixed artifacts and approved references</p><h2>External Evidence</h2><p>Add fixed artifacts only when they exist. Link each one to its source System and the work it supports.</p></div><div class="page-actions">' +
931
+ : '<button class="button primary" type="button" data-new-external-evidence>New Evidence Artifact</button>';
932
+ return '<section class="workflow-section external-evidence-section"><div class="section-head"><div><p class="kicker">Fixed artifacts and approved references</p><h2>Evidence Artifacts</h2><p>Add fixed artifacts only when they exist. Link each one to its source Component and the work it supports.</p></div><div class="page-actions">' +
855
933
  createButton + '<a class="button" href="#/resources/evidence">View all</a></div></div><div class="external-evidence-list">' +
856
- (recent || empty("No External Evidence has been collected yet. Create it during operation only when a real artifact or approved external reference exists.")) +
934
+ (recent || empty("No Evidence Artifacts have been collected yet. Create one during operation only when a real artifact or approved external reference exists.")) +
857
935
  '</div></section>';
858
936
  }
859
937
 
@@ -1002,6 +1080,12 @@ function coverageEnd(coverage) {
1002
1080
  }
1003
1081
 
1004
1082
  function defaultClassificationId() {
1083
+ const classifications = resourcesOfType("classification")
1084
+ .filter(({ record }) => record.status === "active")
1085
+ .map(({ record }) => record.id);
1086
+ if (classifications.length) {
1087
+ return classifications.includes("internal") ? "internal" : classifications[0];
1088
+ }
1005
1089
  const definitions = state.workspace.classificationDefinitions || {};
1006
1090
  return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
1007
1091
  }
@@ -1064,7 +1148,7 @@ function obligationCompletionPlan(item) {
1064
1148
  if (!currentPeopleForParties(item.ownerIds || []).length) {
1065
1149
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
1066
1150
  }
1067
- if (["access-review", "backup-test"].includes(type) && !(state.workspace.systemIds || []).some((id) => state.resources.some(({ record }) => record.id === id && record.status !== "retired"))) {
1151
+ if (["access-review", "backup-test"].includes(type) && !(activeProgram().systemIds || []).some((id) => state.resources.some(({ record }) => record.id === id && record.status !== "retired"))) {
1068
1152
  return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
1069
1153
  }
1070
1154
  if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
@@ -1116,7 +1200,7 @@ function obligationCompletionSeed(type, item, obligation) {
1116
1200
  .filter((record) => record.status === "active" && !responsiblePeople.includes(record.id))
1117
1201
  .map(({ id }) => id);
1118
1202
  const reviewerPeople = independentPeople.length ? [independentPeople[0]] : [];
1119
- const inScopeSystems = resourcesOfType("system").filter(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired").map(({ record }) => record.id);
1203
+ const inScopeSystems = resourcesOfType("system").filter(({ record }) => (activeProgram().systemIds || []).includes(record.id) && record.status !== "retired").map(({ record }) => record.id);
1120
1204
  const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
1121
1205
  const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
1122
1206
  const common = { title };
@@ -1379,8 +1463,11 @@ function openApplicabilityReviewDialog(type, entries) {
1379
1463
  const definition = state.model.resources[type];
1380
1464
  const reviewPoints = definition?.guidance?.reviewPoints || [];
1381
1465
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
1466
+ const reviewedRequirements = reviewedRequirementIds();
1382
1467
  const pending = entries.filter(({ record }) => (
1383
- !record.applicabilityReview
1468
+ type === "requirement" && String(state.model.modelVersion) === "4"
1469
+ ? !reviewedRequirements.has(record.id)
1470
+ : !record.applicabilityReview
1384
1471
  || type === "requirement" && record.applicability === "undetermined"
1385
1472
  ));
1386
1473
  const dialog = document.createElement("dialog");
@@ -1393,10 +1480,12 @@ function openApplicabilityReviewDialog(type, entries) {
1393
1480
  const reviewChecks = reviewPoints.length
1394
1481
  ? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
1395
1482
  : "";
1396
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>' + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
1483
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Batch review</p><h2 id="applicability-dialog-title">Review ' + esc(definition.pluralTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>Record only decisions reviewed against the current service scope. Leave an item at Review later when management has not decided it.</p>' + reviewChecks + '<div class="form-grid review-context"><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label></div><div class="applicability-rows">' + (rows || empty("Every record already has a reviewed applicability decision.")) + '</div><div class="workflow-preview" role="status"></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary" data-preview-review ' + (pending.length ? "" : "disabled") + '>Preview decisions</button></div></form>';
1397
1484
  document.body.append(dialog);
1398
1485
  dialog.showModal();
1399
1486
  const form = dialog.querySelector("form");
1487
+ const saveStatus = dialog.querySelector(".review-save-status");
1488
+ const repositoryPrefetch = prefetchRepositoryForReview(saveStatus);
1400
1489
  dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
1401
1490
  dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
1402
1491
  dialog.addEventListener("close", () => dialog.remove());
@@ -1431,11 +1520,17 @@ function openApplicabilityReviewDialog(type, entries) {
1431
1520
  error.textContent = "Select at least one decision.";
1432
1521
  return;
1433
1522
  }
1523
+ const decisionIds = new Set(decisions.map(({ id }) => id));
1434
1524
  const payload = {
1435
1525
  decisions,
1436
1526
  reviewedByIds: [form.elements.reviewerId.value],
1437
1527
  reviewedOn: form.elements.reviewedOn.value,
1438
- expectedRevisions: Object.fromEntries(entries.map((entry) => [entry.record.id, entry.revision]))
1528
+ expectedRevisions: Object.fromEntries([
1529
+ ...entries.filter((entry) => decisionIds.has(entry.record.id)).map((entry) => [entry.record.id, entry.revision]),
1530
+ ...(type === "requirement" && String(state.model.modelVersion) === "4"
1531
+ ? state.resources.filter(({ record }) => record.id === activeProgram().id).map((entry) => [entry.record.id, entry.revision])
1532
+ : [])
1533
+ ])
1439
1534
  };
1440
1535
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
1441
1536
  try {
@@ -1448,15 +1543,18 @@ function openApplicabilityReviewDialog(type, entries) {
1448
1543
  if (!response.ok) throw new Error(await responseMessage(response));
1449
1544
  const preview = await response.json();
1450
1545
  previewedPayload = payload;
1451
- dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>' + preview.reviewedIds.length + ' decisions will be saved with the reviewer, review date, and current scope recorded automatically.</p>';
1546
+ dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>' + preview.reviewedIds.length + ' ' + pluralize("decision", preview.reviewedIds.length) + ' will be saved with the reviewer, review date, and current scope recorded automatically.</p>';
1452
1547
  dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
1453
1548
  } else {
1549
+ saveStatus.textContent = "Validating and saving…";
1550
+ const prefetch = await repositoryPrefetch;
1454
1551
  const response = await localFetch("/api/applicability-review", {
1455
1552
  method: "POST",
1456
- headers: { "content-type": "application/json" },
1457
- body: JSON.stringify({ ...previewedPayload, confirmed: true })
1553
+ headers: { "content-type": "application/json", prefer: "respond-async" },
1554
+ body: JSON.stringify({ ...previewedPayload, confirmed: true, prefetchToken: prefetch?.token })
1458
1555
  });
1459
1556
  if (!response.ok) throw new Error(await responseMessage(response));
1557
+ saveStatus.textContent = "Saved locally. Refreshing page…";
1460
1558
  applyMutationState(await response.json());
1461
1559
  dialog.close();
1462
1560
  render();
@@ -1491,10 +1589,10 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
1491
1589
  ["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
1492
1590
  ["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
1493
1591
  ["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
1494
- ["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
1592
+ ["Evidence Artifacts", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
1495
1593
  ["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.blocked ? state.obligations.counts.blocked + " blocked" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue || state.obligations.counts.blocked ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
1496
1594
  ];
1497
- const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Confirm the applicable Step 4 records are complete and linked to their Controls.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Confirm each artifact is fixed, verified, and linked to its source System and Controls.</p></a></div></section>';
1595
+ const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Confirm the applicable Step 4 records are complete and linked to their Controls.</p></a><a href="#/resources/evidence"><span class="step-label">Evidence Artifacts</span><h4>Review imported or referenced proof</h4><p>Confirm each artifact is fixed, verified, and linked to its source Component and Controls.</p></a></div></section>';
1498
1596
  const dateFields = typeOne
1499
1597
  ? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
1500
1598
  : '<label><span>Period start</span><input type="date" name="start" required value="' + esc(start) + '"></label><label><span>Period end</span><input type="date" name="end" required value="' + esc(end) + '"></label>';
@@ -1595,10 +1693,10 @@ function renderPacketResults(container, result) {
1595
1693
  container.innerHTML = '<section class="metrics packet-metrics">' +
1596
1694
  metric("filegrc Evidence", packet.summary.filegrcRecords, packet.summary.records + " total packet records", "neutral") +
1597
1695
  metric("Obligations", packet.summary.obligationOccurrences, packet.summary.eventRuns + " event workflows", "neutral") +
1598
- metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
1696
+ metric("Evidence Artifacts", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
1599
1697
  metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
1600
1698
  '</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "filegrc management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
1601
- '<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.artifactKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
1699
+ '<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included Evidence Artifacts</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.artifactKind)) + '</small></a>').join("") + '</div>' : empty("No Evidence Artifacts matched.")) + '</section></div>';
1602
1700
  }
1603
1701
 
1604
1702
  function obligationPreview(items) {
@@ -1692,10 +1790,12 @@ function renderList(main, type, params = new URLSearchParams()) {
1692
1790
  const values = [...new Set(observed)].sort();
1693
1791
  return { name, label: field.label || humanize(name), values };
1694
1792
  }).filter(({ values }) => values.length > 1);
1695
- const createButton = !state.readOnly && !definition.singleton ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
1793
+ const createButton = !state.readOnly && !definition.singleton && !collectionNeedsFirstRecord(type) && resourceCreationAllowed(type) ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
1794
+ const reviewedRequirements = reviewedRequirementIds();
1696
1795
  const hasPendingApplicability = entries.some(({ record }) => (
1697
- !record.applicabilityReview
1698
- || type === "requirement" && record.applicability === "undetermined"
1796
+ type === "requirement" && String(state.model.modelVersion) === "4"
1797
+ ? !reviewedRequirements.has(record.id)
1798
+ : !record.applicabilityReview || type === "requirement" && record.applicability === "undetermined"
1699
1799
  ));
1700
1800
  const applicabilityButton = !state.readOnly
1701
1801
  && ["requirement", "control", "commitment", "complementary-control"].includes(type)
@@ -1723,7 +1823,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1723
1823
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
1724
1824
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
1725
1825
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
1726
- main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : state.collectionReviews?.[type] ? "No records exist. Use the scope confirmation above to record an allowed zero population or add the records management identified." : definition.guidance?.emptyState || "No records exist. Use the page guidance to decide whether a record is required, then add only real program facts.") + '</td></tr>';
1826
+ main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
1727
1827
  pagination.hidden = totalPages === 1;
1728
1828
  previous.disabled = pageNumber === 1;
1729
1829
  next.disabled = pageNumber === totalPages;
@@ -1765,7 +1865,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1765
1865
  main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
1766
1866
  main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
1767
1867
  main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
1768
- if (params.get("new") === "1" && !state.readOnly && !definition.singleton) queueMicrotask(() => openEditor(type));
1868
+ if (params.get("new") === "1" && !state.readOnly && !definition.singleton && resourceCreationAllowed(type)) queueMicrotask(() => openEditor(type));
1769
1869
  if (params.get("review") === "1" && !state.readOnly) {
1770
1870
  queueMicrotask(() => main.querySelector("#review-applicability")?.click());
1771
1871
  }
@@ -1871,10 +1971,16 @@ function renderDetail(main, type, id) {
1871
1971
  main.querySelector("[data-evidence-file]")?.click();
1872
1972
  });
1873
1973
  main.querySelector("[data-evidence-file]")?.addEventListener("change", async (event) => {
1874
- const file = event.currentTarget.files?.[0];
1974
+ const input = event.currentTarget;
1975
+ const file = input.files?.[0];
1875
1976
  if (!file) return;
1876
- if (!confirm('Attach "' + file.name + '" to this Evidence record? Confirm that its classification, retention, and repository access are appropriate.')) {
1877
- event.currentTarget.value = "";
1977
+ if (!await confirmAction({
1978
+ kicker: "Attach evidence",
1979
+ title: file.name,
1980
+ message: "Confirm its classification, retention, and repository access are appropriate.",
1981
+ confirmLabel: "Attach"
1982
+ })) {
1983
+ input.value = "";
1878
1984
  return;
1879
1985
  }
1880
1986
  try {
@@ -1887,12 +1993,19 @@ function renderDetail(main, type, id) {
1887
1993
  applyMutationState(await response.json());
1888
1994
  render();
1889
1995
  } catch (error) {
1996
+ input.value = "";
1890
1997
  showError(error.message);
1891
1998
  }
1892
1999
  });
1893
2000
  main.querySelectorAll("[data-detach-evidence]").forEach((button) => button.addEventListener("click", async () => {
1894
2001
  const attachment = button.dataset.detachEvidence;
1895
- if (!confirm('Remove "' + attachment + '" from this Evidence record and delete its local file?')) return;
2002
+ if (!await confirmAction({
2003
+ kicker: "Remove attachment",
2004
+ title: attachment,
2005
+ message: "Deletes this local file from the Evidence Artifact.",
2006
+ confirmLabel: "Remove",
2007
+ danger: true
2008
+ })) return;
1896
2009
  try {
1897
2010
  const response = await localFetch(
1898
2011
  "/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(attachment)
@@ -1909,7 +2022,13 @@ function renderDetail(main, type, id) {
1909
2022
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
1910
2023
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
1911
2024
  main.querySelector("#delete-resource")?.addEventListener("click", async () => {
1912
- if (!confirm('Delete "' + entry.record.title + '"? Use deletion only for mistakes and uncommitted drafts. Unshared Markdown authored for this record will also be deleted.')) return;
2025
+ if (!await confirmAction({
2026
+ kicker: "Delete record",
2027
+ title: entry.record.title,
2028
+ message: "Deletes this record and its Markdown. Use this only for mistakes and uncommitted drafts.",
2029
+ confirmLabel: "Delete",
2030
+ danger: true
2031
+ })) return;
1913
2032
  try {
1914
2033
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
1915
2034
  if (!response.ok) return showError(await responseMessage(response));
@@ -2175,13 +2294,10 @@ function renderTrunkRepository(main) {
2175
2294
  const lastSync = repository.lastSuccessfulSynchronization
2176
2295
  ? formatLocalDateTime(repository.lastSuccessfulSynchronization)
2177
2296
  : "No successful sync recorded by this server";
2178
- const override = repository.developmentOverride
2179
- ? '<div class="repository-override"><span class="status-dot warn"></span><div><strong>Development write override active</strong><p>Browser writes stay local. FileGRC will not fetch, commit, or push while this server uses <code>--allow-non-authoritative-writes</code>.</p></div></div>'
2180
- : "";
2181
2297
  const validationBody = state.validation.diagnostics.length
2182
2298
  ? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
2183
2299
  : empty("No validation problems.");
2184
- main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div>' + override + '<section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
2300
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
2185
2301
  main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
2186
2302
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
2187
2303
  }
@@ -2369,13 +2485,14 @@ function rendererSettingsEntry() {
2369
2485
  return state.resources.find(({ record }) => record.type === "renderer-settings");
2370
2486
  }
2371
2487
 
2372
- function requestOnboarding() {
2488
+ function requestOnboarding({ setupOnly = false } = {}) {
2373
2489
  if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
2374
2490
  if (parseRoute().name !== "home") {
2375
2491
  history.replaceState(null, "", "#/");
2376
2492
  render();
2377
2493
  }
2378
- onboardingStep = 0;
2494
+ onboardingStep = setupOnly ? onboardingSteps().length - 1 : 0;
2495
+ onboardingSetupOnly = setupOnly;
2379
2496
  onboardingDraft = initialOnboardingDraft();
2380
2497
  onboardingBusy = false;
2381
2498
  onboardingShade = document.createElement("div");
@@ -2400,23 +2517,25 @@ function requestOnboarding() {
2400
2517
  onboardingDialog = null;
2401
2518
  onboardingDraft = null;
2402
2519
  onboardingBusy = false;
2520
+ onboardingSetupOnly = false;
2403
2521
  onboardingPendingDraft = false;
2404
2522
  });
2405
2523
  renderOnboardingStep();
2406
2524
  }
2407
2525
 
2408
2526
  function initialOnboardingDraft() {
2409
- const systemEntry = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired");
2527
+ const program = activeProgram();
2528
+ const system = initialSetupSystem();
2410
2529
  const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
2411
2530
  return {
2412
- systemId: systemEntry?.record.id || "",
2413
- serviceName: systemEntry?.record.title || "",
2414
- scope: systemEntry?.record.description || "",
2415
- ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
2416
- criticality: systemEntry?.record.criticality || "high",
2417
- classificationId: systemEntry?.record.classificationId || defaultClassificationId(),
2418
- internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
2419
- programGoal: programGoalFromKind(state.workspace.assuranceGoal)
2531
+ systemId: system?.id || "",
2532
+ serviceName: system?.title || "",
2533
+ scope: system?.boundary || system?.description || "",
2534
+ ownerId: system?.ownerIds?.[0] || owner?.id || "",
2535
+ criticality: system?.criticality || "high",
2536
+ classificationId: system?.classificationId || defaultClassificationId(),
2537
+ internetExposed: system?.internetExposed === false ? "false" : "true",
2538
+ programGoal: programGoalFromKind(program.assuranceGoal)
2420
2539
  };
2421
2540
  }
2422
2541
 
@@ -2425,60 +2544,51 @@ function onboardingSteps() {
2425
2544
  target: ".repo-chip",
2426
2545
  kicker: "Mental model",
2427
2546
  title: "Files are the program",
2428
- body: "You or an agent add JSON records, Markdown, and evidence attachments under data/. This renderer edits those files, and Git records their history.",
2547
+ body: "Source files live under data/. The UI, CLI, and direct edits change the same files. Git records the history.",
2429
2548
  points: [
2430
- "Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
2431
- "JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
2432
- "Record status represents approval. In trunk mode, the browser validates, commits, and pushes each save; agents and terminal users manage Git explicitly."
2549
+ "JSON holds fields. Markdown holds long-form content.",
2550
+ "Record status tracks approval.",
2551
+ "Trunk-mode browser saves validate, commit, and push."
2433
2552
  ]
2434
2553
  };
2435
2554
  const path = {
2436
2555
  target: ".readiness-map",
2437
2556
  kicker: "Program model",
2438
2557
  title: "Follow the audit chain",
2439
- body: "Follow the five program steps in order. Each step reveals the records and decisions needed next.",
2440
- points: [
2441
- "Define the people, criteria, service, Systems, and providers in scope.",
2442
- "Approve the policies, implement the controls, then operate them and retain dated proof.",
2443
- "Create the audit engagement when a CPA firm is involved or a real customer deadline requires it."
2444
- ]
2558
+ body: "Follow five steps. Each page shows the next decision.",
2445
2559
  };
2446
2560
  const operation = {
2447
2561
  target: ".obligation-panel",
2448
2562
  kicker: "Program operation",
2449
- title: "Work the queue and trigger policy events",
2450
- body: "Recurring work and assigned follow-up appear in the Work Queue with owners, allowed completion dates, and overdue cutoffs. When a listed event happens, start its Policy Event to create the applicable tasks and deadlines.",
2563
+ title: "Run work and record changes",
2564
+ body: "Work Queue holds scheduled and assigned tasks. Policy Events add tasks when a listed change occurs.",
2451
2565
  points: [
2452
- "Complete each occurrence with the requested dated operating record and supporting evidence.",
2453
- "Start Policy Events only after the real event occurs. FileGRC creates one owned Action Item for each applicable policy step.",
2454
- "The browser and CLI use the same schedules, event rules, and completion checks."
2566
+ "Open a task to see its owner, due date, and proof.",
2567
+ "Record events after they happen."
2455
2568
  ]
2456
2569
  };
2457
2570
  const auditPath = {
2458
2571
  target: null,
2459
2572
  kicker: "Report goal and audit",
2460
- title: "Choose the report goal and plan fieldwork",
2461
- body: [
2462
- "SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
2463
- "Most customer requests focus on Security. Add another category only when the service and customer need call for it. Choose a management goal now, then create an Audit record only after a real CPA engagement or customer deadline exists."
2464
- ],
2573
+ title: "Choose a goal",
2574
+ body: "SOC 2 is an independent CPA report on controls tied to selected Trust Services Criteria.",
2465
2575
  sections: [
2466
2576
  {
2467
2577
  title: "Type 1",
2468
- body: "The CPA evaluates control design and implementation at a point in time. Type 1 is optional before Type 2."
2578
+ body: "Design and implementation at a point in time. Optional before Type 2."
2469
2579
  },
2470
2580
  {
2471
2581
  title: "Type 2",
2472
- body: "The CPA evaluates operation across an agreed period. Dated evidence and complete populations must cover that period."
2582
+ body: "Operation across a period. Evidence and populations must cover it."
2473
2583
  }
2474
2584
  ],
2475
- afterSections: "FileGRC prepares the records and packet. The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
2585
+ afterSections: "FileGRC prepares the records and evidence packet. The CPA tests and reports."
2476
2586
  };
2477
2587
  const setup = {
2478
2588
  target: null,
2479
2589
  kicker: "Initial scope",
2480
- title: "Describe the service you plan to audit",
2481
- body: "Create the first in-scope System and record management’s goal. Step 1 will guide the rest of the scope."
2590
+ title: "Describe the service in scope",
2591
+ body: "Create the first System and choose the program goal."
2482
2592
  };
2483
2593
  return [
2484
2594
  files,
@@ -2505,10 +2615,10 @@ function renderOnboardingStep() {
2505
2615
  ? onboardingSetupForm()
2506
2616
  : description + explanation + afterSections;
2507
2617
  const finalActions = onboardingStep === steps.length - 1
2508
- ? '<span class="onboarding-save-status" role="status" aria-live="polite"></span><button class="button" type="button" data-onboarding="draft">Save as planned</button><button class="button primary" type="button" data-onboarding="next">Confirm service scope</button>'
2618
+ ? '<span class="onboarding-save-status" role="status" aria-live="polite"></span><button class="button" type="button" data-onboarding="draft">Save as planned</button><button class="button primary" type="button" data-onboarding="next">Confirm scope</button>'
2509
2619
  : '<button class="button primary" type="button" data-onboarding="next">Next</button>';
2510
- onboardingDialog.innerHTML = '<div class="onboarding-progress" style="--onboarding-step-count:' + steps.length + '" aria-label="Onboarding step ' + (onboardingStep + 1) + ' of ' + steps.length + '">' + progress + '</div><div class="onboarding-scroll"><div class="onboarding-head"><p class="kicker">' + esc(step.kicker) + ' · ' + (onboardingStep + 1) + ' of ' + steps.length + '</p><h2 id="onboarding-title">' + esc(titleCase(step.title)) + '</h2></div>' + body + '<div class="dialog-error" role="alert"></div></div><div class="dialog-actions onboarding-actions"><button class="button text-button onboarding-skip" type="button" data-onboarding="skip">Skip onboarding</button>' + (onboardingStep ? '<button class="button" type="button" data-onboarding="back">Back</button>' : "") + finalActions + '</div>';
2511
- onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
2620
+ onboardingDialog.innerHTML = '<div class="onboarding-progress" style="--onboarding-step-count:' + steps.length + '" aria-label="Onboarding step ' + (onboardingStep + 1) + ' of ' + steps.length + '">' + progress + '</div><div class="onboarding-scroll"><div class="onboarding-head"><p class="kicker">' + esc(step.kicker) + ' · ' + (onboardingStep + 1) + ' of ' + steps.length + '</p><h2 id="onboarding-title">' + esc(titleCase(step.title)) + '</h2></div>' + body + '<div class="dialog-error" role="alert"></div></div><div class="dialog-actions onboarding-actions"><button class="button text-button onboarding-skip" type="button" data-onboarding="skip">' + (onboardingSetupOnly ? "Close" : "Skip onboarding") + '</button>' + (onboardingStep ? '<button class="button" type="button" data-onboarding="back">Back</button>' : "") + finalActions + '</div>';
2621
+ onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", onboardingSetupOnly ? closeOnboarding : cancelOnboarding);
2512
2622
  onboardingDialog.querySelector('[data-onboarding="back"]')?.addEventListener("click", () => {
2513
2623
  captureOnboardingForm();
2514
2624
  onboardingStep -= 1;
@@ -2541,16 +2651,21 @@ function renderOnboardingStep() {
2541
2651
 
2542
2652
  function onboardingSetupForm() {
2543
2653
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
2544
- const classifications = Object.keys(state.workspace.classificationDefinitions || {});
2654
+ const resourceClassifications = resourcesOfType("classification")
2655
+ .filter(({ record }) => record.status === "active")
2656
+ .map(({ record }) => record.id);
2657
+ const classifications = resourceClassifications.length
2658
+ ? resourceClassifications
2659
+ : Object.keys(state.workspace.classificationDefinitions || {});
2545
2660
  if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
2546
2661
  classifications.push(onboardingDraft.classificationId);
2547
2662
  }
2548
2663
  const gitStatus = state.repository?.mode === "trunk"
2549
- ? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related workspace, system, and renderer changes in one local commit, then push it in the background." : state.repository.message) + '</small></span></div>'
2664
+ ? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related Workspace, Program, System, Component, and renderer changes in one local commit, then push it in the background." : state.repository.message) + '</small></span></div>'
2550
2665
  : state.git.available && state.git.branch
2551
2666
  ? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Manual repository mode</strong><small>Setup changes will stay local until you commit and synchronize them.</small></span></div>'
2552
2667
  : '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Manual-mode writes still work, but Git history is unavailable until the repository is configured.</small></span></div>';
2553
- return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="classificationId" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.classificationId ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">Save as planned keeps the System planned. Confirm service scope makes it active. Both add it to the Workspace scope.</p>';
2668
+ return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="classificationId" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.classificationId ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No assurance goal yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>Sets management intent. Audits and report periods are separate.</small></label></form><p class="onboarding-write-note">Planned saves a draft. Confirm scope activates it. Both add it to the Program.</p>';
2554
2669
  }
2555
2670
 
2556
2671
  function captureOnboardingForm() {
@@ -2830,8 +2945,9 @@ function openEditor(type, entry = null, options = {}) {
2830
2945
  const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
2831
2946
  const editorDescription = options.description
2832
2947
  || implementationEditorDescription(type)
2833
- || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
2834
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : options.actionCompletion ? "Complete assigned work" : options.obligationCompletion ? "Record obligation work" : "Create record") + '</p><h2 id="resource-editor-title">' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '</h2></div><button type="button" class="icon-button" data-editor-dismiss aria-label="Close">×</button></div><p>' + esc(editorDescription) + '</p>' + resourceReviewCriteria(type) + '<div class="form-grid">' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name))).join("") + '</div>' +
2948
+ || conciseResourceDescription(definition)
2949
+ || "Add the facts known now.";
2950
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : options.actionCompletion ? "Complete assigned work" : options.obligationCompletion ? "Record obligation work" : "Create record") + '</p><h2 id="resource-editor-title">' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '</h2></div><button type="button" class="icon-button" data-editor-dismiss aria-label="Close">×</button></div><p>' + esc(editorDescription) + '</p>' + resourceReviewCriteria(type, true) + '<div class="form-grid">' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name))).join("") + '</div>' +
2835
2951
  activeMarkdown.map((markdown) => {
2836
2952
  const generated = !entry?.content?.[markdown.name];
2837
2953
  const source = entry?.content?.[markdown.name]?.source
@@ -2850,6 +2966,8 @@ function openEditor(type, entry = null, options = {}) {
2850
2966
  dialog.addEventListener("close", () => dialog.remove());
2851
2967
  dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
2852
2968
  wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
2969
+ wireStructuredObjectEditors(dialog);
2970
+ wireChoicePickers(dialog);
2853
2971
  dialog.querySelector(".advanced-editor textarea").addEventListener("input", () => {
2854
2972
  dialog.dataset.jsonDirty = "true";
2855
2973
  dialog.querySelector("form").noValidate = true;
@@ -2935,22 +3053,79 @@ function openEditor(type, entry = null, options = {}) {
2935
3053
  }
2936
3054
 
2937
3055
  function implementationEditorDescription(type) {
3056
+ if (type === "person") {
3057
+ return "Record someone who owns, reviews, approves, or performs program work.";
3058
+ }
3059
+ if (type === "appointment") {
3060
+ return "Assign named authority to one person for a defined period.";
3061
+ }
3062
+ if (type === "program") {
3063
+ return "Choose the goal, scope, owners, criteria, controls, and risk method.";
3064
+ }
2938
3065
  if (type === "system") {
2939
- return "Complete the System inventory fields. If this System produces control evidence, add its evidence source roles and current access owners, then write the exact report, filters, date range, timezone, export format, and reconciliation steps in Record Markdown.";
3066
+ return "Define the service boundary, owners, information, and recovery needs.";
3067
+ }
3068
+ if (type === "component") {
3069
+ return "Connect this Component to its Systems and select any evidence roles.";
2940
3070
  }
2941
3071
  if (type === "control") {
2942
- return "Document the actual procedure and select every authoritative System that produces evidence for this Control. Before marking it implemented, confirm each source is active, has the required evidence role and current access owners, and includes repeatable retrieval instructions in Record Markdown.";
3072
+ return "Describe the procedure, Systems, Components, and evidence sources.";
3073
+ }
3074
+ if (type === "evidence") {
3075
+ return "Add a retained export, report, screenshot, signed file, or approved external reference.";
3076
+ }
3077
+ if (type === "audit") {
3078
+ return "Record a real CPA engagement or a customer deadline that requires audit planning.";
3079
+ }
3080
+ if (type === "audit-request") {
3081
+ return "Track one auditor request, owner, due date, response, and evidence.";
3082
+ }
3083
+ if (type === "vendor") {
3084
+ return "Record a material external provider relationship.";
3085
+ }
3086
+ if (type === "classification") {
3087
+ return "Define one information handling level and its rank.";
3088
+ }
3089
+ if (type === "information-type") {
3090
+ return "Name an information category and its default handling level.";
3091
+ }
3092
+ if (type === "policy") {
3093
+ return "Define a program rule, its scope, owner, reviewer, and dates.";
3094
+ }
3095
+ if (type === "obligation") {
3096
+ return "Schedule recurring or event-driven work.";
3097
+ }
3098
+ if (type === "risk") {
3099
+ return "Track one threat or business impact and its treatment.";
3100
+ }
3101
+ if (type === "risk-assessment") {
3102
+ return "Evaluate a defined scope with the Program risk method.";
3103
+ }
3104
+ if (type === "finding") {
3105
+ return "Track a confirmed gap that needs its own remediation.";
3106
+ }
3107
+ if (type === "exception") {
3108
+ return "Record an approved, time-bound departure from a Policy or Control.";
3109
+ }
3110
+ if (type === "action-item") {
3111
+ return "Assign one dated follow-up task and its completion proof.";
2943
3112
  }
2944
3113
  return "";
2945
3114
  }
2946
3115
 
3116
+ function conciseResourceDescription(definition) {
3117
+ const description = definition?.description?.trim() || "";
3118
+ return description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() || description;
3119
+ }
3120
+
2947
3121
  function seedRecord(type, definition) {
2948
3122
  const record = { id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
2949
3123
  const fields = { ...state.model.commonFields, ...definition.fields };
2950
3124
  for (const name of definition.required || []) {
2951
3125
  const field = fields[name];
2952
3126
  if (record[name] !== undefined) continue;
2953
- if (field.relation) {
3127
+ if (field.default !== undefined) record[name] = structuredClone(field.default);
3128
+ else if (field.relation) {
2954
3129
  const candidates = relationCandidates(field);
2955
3130
  record[name] = field.type === "array" ? (candidates.length === 1 ? [candidates[0].record.id] : []) : (candidates.length === 1 ? candidates[0].record.id : "");
2956
3131
  }
@@ -3004,12 +3179,18 @@ function renderRecordContentEditor(type, entry, options) {
3004
3179
  if (!config) return "";
3005
3180
  const item = entry?.content?.[config.slot];
3006
3181
  const source = item?.source || "";
3007
- const editor = '<label class="content-editor-field record-content-editor"><span>' + esc(config.label) + ' Markdown <small>optional</small></span><textarea data-record-content spellcheck="true" placeholder="Document the work performed, method, results, decisions, and follow-up.">' + esc(source) + '</textarea></label>';
3182
+ const editor = '<label class="content-editor-field record-content-editor"><span>' + esc(config.label) + ' Markdown <small>optional</small></span><textarea data-record-content spellcheck="true" placeholder="' + esc(recordContentPlaceholder(type)) + '">' + esc(source) + '</textarea></label>';
3008
3183
  if (config.mode === "default") return editor;
3009
3184
  const open = item || options.addRecordContent;
3010
3185
  return '<details class="record-content-details" ' + (open ? "open" : "") + '><summary>' + (item ? "Record Markdown" : "Add Record Markdown") + '</summary><p>Use this when the structured fields do not capture the full record.</p>' + editor + '</details>';
3011
3186
  }
3012
3187
 
3188
+ function recordContentPlaceholder(type) {
3189
+ if (type === "system") return "Add architecture, dependencies, or boundary details.";
3190
+ if (type === "component") return "Add operating details or evidence retrieval steps.";
3191
+ return "Document the work, results, decisions, and follow-up.";
3192
+ }
3193
+
3013
3194
  function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired) {
3014
3195
  const label = fieldLabel(type, name);
3015
3196
  const requiredMark = required || field.requiredWhen || oneOfRequired
@@ -3027,21 +3208,22 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3027
3208
  if (field.relation && field.type === "array") {
3028
3209
  const candidates = relationCandidates(field);
3029
3210
  control = candidates.length
3030
- ? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title + " · " + record.id) + '</small></span></label>').join("") + '</div>'
3031
- : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
3211
+ ? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title) + '</small></span></label>').join("") + '</div>'
3212
+ : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
3032
3213
  return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
3033
3214
  }
3034
3215
  if (field.relation) {
3035
3216
  const candidates = relationCandidates(field);
3036
3217
  control = candidates.length
3037
- ? '<select><option value="">Select a Resource</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + ' · ' + esc(record.id) + '</option>').join("") + '</select>'
3038
- : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
3218
+ ? '<select><option value="">Select ' + esc(relationTypeLabel(field).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>'
3219
+ : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
3039
3220
  return fieldWrap(name, "relation", label, requiredMark, control, help, required);
3040
3221
  }
3041
3222
  if (name === "classificationId") {
3042
- const values = Object.keys(state.workspace.classificationDefinitions || {});
3223
+ const classifications = resourcesOfType("classification").filter(({ record }) => record.status === "active");
3224
+ const values = classifications.length ? classifications.map(({ record }) => record.id) : Object.keys(state.workspace.classificationDefinitions || {});
3043
3225
  control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
3044
- return fieldWrap(name, "string", label, requiredMark, control, "Defined by Workspace classificationDefinitions", required);
3226
+ return fieldWrap(name, "string", label, requiredMark, control, classifications.length ? "References an active Classification" : "Defined by Workspace classificationDefinitions", required);
3045
3227
  }
3046
3228
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
3047
3229
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
@@ -3053,15 +3235,30 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3053
3235
  return fieldWrap(name, "boolean", label, requiredMark, control, help, required);
3054
3236
  }
3055
3237
  if (field.type === "object") {
3238
+ const schema = state.model.objectTypes[field.objectType];
3239
+ if (schema?.additionalProperties === false) {
3240
+ control = '<div class="structured-object-fields" data-object-type="' + esc(field.objectType) + '">' + objectPropertyFields(schema, value || {}) + '</div>';
3241
+ return fieldWrap(name, "structured-object", label, requiredMark, control, "Fill only the details that apply.", required);
3242
+ }
3056
3243
  control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
3057
3244
  return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
3058
3245
  }
3059
3246
  if (field.type === "array") {
3247
+ if (field.items === "object") {
3248
+ const schema = state.model.objectTypes[field.itemObjectType];
3249
+ const items = Array.isArray(value) ? value : [];
3250
+ control = '<div class="object-array-items">' + items.map((item) => objectArrayItem(schema, item)).join("") + '</div><button class="button" type="button" data-add-object-item>Add ' + esc(humanize(field.itemObjectType || "item")) + '</button><template>' + objectArrayItem(schema, {}) + '</template>';
3251
+ return fieldWrap(name, "object-array", label, requiredMark, control, "Add one row for each relationship or scoped decision.", required);
3252
+ }
3253
+ if (name === "evidenceSourceKinds") {
3254
+ const selected = Array.isArray(value) ? value : [];
3255
+ const options = evidenceSourceRoleOptions();
3256
+ const summary = selected.length ? selected.length + " selected" : "Choose roles";
3257
+ control = '<details class="choice-picker"><summary><span data-choice-summary>' + esc(summary) + '</span></summary><div class="checkbox-list">' + options.map((option) => '<label><input type="checkbox" value="' + esc(option) + '" ' + (selected.includes(option) ? "checked" : "") + '><span>' + esc(properCase(option)) + '</span></label>').join("") + '</div></details>';
3258
+ return fieldWrap(name, "choice-array", label, requiredMark, control, "Select only roles this Component performs.", required);
3259
+ }
3060
3260
  control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
3061
- const arrayHelp = name === "evidenceSourceKinds"
3062
- ? "One role per line. Use the roles required by the Controls this System supports, such as " + evidenceSourceRoleOptions().join(", ") + "."
3063
- : "One value per line";
3064
- return fieldWrap(name, "array", label, requiredMark, control, arrayHelp, required);
3261
+ return fieldWrap(name, "array", label, requiredMark, control, "One value per line", required);
3065
3262
  }
3066
3263
  if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
3067
3264
  control = '<textarea>' + esc(value ?? "") + '</textarea>';
@@ -3079,6 +3276,178 @@ function evidenceSourceRoleOptions() {
3079
3276
  return [...new Set((state.model.evidenceSourceFamilies || []).flatMap(({ sourceKinds }) => sourceKinds || []))].sort();
3080
3277
  }
3081
3278
 
3279
+ function wireChoicePickers(dialog) {
3280
+ dialog.querySelectorAll(".choice-picker").forEach((picker) => {
3281
+ const sync = () => {
3282
+ const count = picker.querySelectorAll('input[type="checkbox"]:checked').length;
3283
+ picker.querySelector("[data-choice-summary]").textContent = count ? count + " selected" : "Choose roles";
3284
+ };
3285
+ picker.addEventListener("change", sync);
3286
+ sync();
3287
+ });
3288
+ }
3289
+
3290
+ function objectPropertyFields(schema, value = {}) {
3291
+ const required = new Set(schema?.required || []);
3292
+ return Object.entries(schema?.properties || {}).map(([name, property]) => {
3293
+ const requiredNow = required.has(name) && !property.requiredWhen;
3294
+ const mark = required.has(name) || property.requiredWhen
3295
+ ? '<span class="required-mark" ' + (requiredNow ? "" : "hidden") + '>Required</span>'
3296
+ : "";
3297
+ const stringMap = property.type === "object" && state.model.objectTypes[property.objectType]?.additionalProperties?.type === "string";
3298
+ let input;
3299
+ if (property.relation && property.type === "id") {
3300
+ const candidates = relationCandidates(property);
3301
+ input = '<select><option value="">Select ' + esc(relationTypeLabel(property).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value[name] === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>';
3302
+ } else if (property.relation && property.type === "array") {
3303
+ const candidates = relationCandidates(property);
3304
+ input = candidates.length
3305
+ ? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value[name] || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title) + '</small></span></label>').join("") + '</div>'
3306
+ : '<div class="missing-options">No matching ' + esc(relationTypeLabel(property, true).toLowerCase()) + ' yet.</div>';
3307
+ } else if (property.type === "array" && property.values) {
3308
+ input = '<div class="checkbox-list">' + property.values.map((option) => '<label><input type="checkbox" value="' + esc(option) + '" ' + ((value[name] || []).includes(option) ? "checked" : "") + '><span>' + esc(properCase(option)) + '</span></label>').join("") + '</div>';
3309
+ } else if (property.type === "array") {
3310
+ input = '<textarea placeholder="One value per line">' + esc((value[name] || []).join("\n")) + '</textarea>';
3311
+ } else if (property.type === "enum" || property.type === "rating") {
3312
+ const options = property.values || (property.registry ? Object.keys(state.model[property.registry] || {}) : state.model.primitives[property.type] || []);
3313
+ input = '<select><option value="">Select</option>' + options.map((option) => '<option value="' + esc(option) + '" ' + (value[name] === option ? "selected" : "") + '>' + esc(properCase(option)) + '</option>').join("") + '</select>';
3314
+ } else if (property.type === "object") {
3315
+ const nested = state.model.objectTypes[property.objectType];
3316
+ input = nested?.additionalProperties === false
3317
+ ? '<div class="structured-object-fields nested" data-object-type="' + esc(property.objectType) + '">' + objectPropertyFields(nested, value[name] || {}) + '</div>'
3318
+ : nested?.additionalProperties?.type === "string"
3319
+ ? stringMapEditor(value[name] || {}, name)
3320
+ : '<textarea spellcheck="false" placeholder="{ }">' + esc(value[name] === undefined ? "" : JSON.stringify(value[name], null, 2)) + '</textarea>';
3321
+ } else if (property.type === "boolean") {
3322
+ input = '<select><option value="">Not set</option><option value="true" ' + (value[name] === true ? "selected" : "") + '>Yes</option><option value="false" ' + (value[name] === false ? "selected" : "") + '>No</option></select>';
3323
+ } else {
3324
+ const multiline = ["rationale", "description", "notes"].some((part) => name.toLowerCase().includes(part));
3325
+ input = multiline
3326
+ ? '<textarea>' + esc(value[name] || "") + '</textarea>'
3327
+ : '<input type="' + (property.type === "date" ? "date" : property.type === "timestamp" ? "datetime-local" : ["integer", "number"].includes(property.type) ? "number" : "text") + '" value="' + esc(property.type === "timestamp" && value[name] ? String(value[name]).slice(0, 16) : value[name] ?? "") + '"' + (property.minimum !== undefined ? ' min="' + esc(property.minimum) + '"' : "") + '>';
3328
+ }
3329
+ return '<div class="object-property' + (stringMap ? " string-map-property" : "") + '" data-object-field="' + esc(name) + '" data-object-kind="' + esc(property.type) + '" data-object-required="' + (required.has(name) || property.requiredWhen ? "true" : "false") + '"' + (property.requiredWhen ? ' data-object-required-when="' + esc(JSON.stringify(property.requiredWhen)) + '"' : "") + (property.allowedWhen ? ' data-object-allowed-when="' + esc(JSON.stringify(property.allowedWhen)) + '"' : "") + '><span class="object-property-label">' + esc(humanize(name)) + mark + '</span>' + input + '</div>';
3330
+ }).join("");
3331
+ }
3332
+
3333
+ function objectArrayItem(schema, value = {}) {
3334
+ return '<fieldset class="object-array-item"><legend>' + esc(humanize(schema?.title || "Relationship")) + '</legend><div class="structured-object-fields">' + objectPropertyFields(schema, value) + '</div><button type="button" class="text-button" data-remove-object-item>Remove</button></fieldset>';
3335
+ }
3336
+
3337
+ function stringMapEditor(value = {}, name = "item") {
3338
+ const entries = Object.entries(value);
3339
+ const row = ([key = "", item = ""] = []) => '<div class="string-map-row"><input data-map-key value="' + esc(key) + '" placeholder="Name"><input data-map-value value="' + esc(item) + '" placeholder="Value"><button type="button" class="text-button" data-remove-string-map aria-label="Remove ' + esc(humanize(name).toLowerCase()) + '">Remove</button></div>';
3340
+ return '<div class="string-map-editor"><div class="string-map-items">' + (entries.length ? entries.map(row).join("") : row()) + '</div><button type="button" class="button" data-add-string-map>Add ' + esc(humanize(name).replace(/s$/, "").toLowerCase()) + '</button><template>' + row() + '</template></div>';
3341
+ }
3342
+
3343
+ function wireStructuredObjectEditors(dialog) {
3344
+ dialog.querySelectorAll('[data-kind="object-array"]').forEach((group) => {
3345
+ group.querySelector('[data-add-object-item]')?.addEventListener("click", () => {
3346
+ const template = group.querySelector("template");
3347
+ group.querySelector(".object-array-items").append(template.content.cloneNode(true));
3348
+ refresh(group.querySelector(".object-array-items > .object-array-item:last-child > .structured-object-fields"));
3349
+ });
3350
+ group.addEventListener("click", (event) => {
3351
+ const remove = event.target.closest("[data-remove-object-item]");
3352
+ if (!remove) return;
3353
+ remove.closest(".object-array-item")?.remove();
3354
+ });
3355
+ });
3356
+ dialog.querySelector("form").addEventListener("click", (event) => {
3357
+ const add = event.target.closest("[data-add-string-map]");
3358
+ if (add) {
3359
+ const editor = add.closest(".string-map-editor");
3360
+ editor.querySelector(".string-map-items").append(editor.querySelector("template").content.cloneNode(true));
3361
+ editor.querySelector(".string-map-row:last-child [data-map-key]").focus();
3362
+ return;
3363
+ }
3364
+ const remove = event.target.closest("[data-remove-string-map]");
3365
+ if (!remove) return;
3366
+ const editor = remove.closest(".string-map-editor");
3367
+ const rows = editor.querySelectorAll(".string-map-row");
3368
+ if (rows.length > 1) remove.closest(".string-map-row").remove();
3369
+ else rows[0].querySelectorAll("input").forEach((input) => { input.value = ""; });
3370
+ });
3371
+ const refresh = (container) => {
3372
+ const fields = [...container.querySelectorAll(":scope > [data-object-field]")];
3373
+ const values = Object.fromEntries(fields.map((field) => [field.dataset.objectField, readObjectProperty(field)]));
3374
+ const hasValue = Object.values(values).some((value) => value !== undefined && (!Array.isArray(value) || value.length) && (typeof value !== "object" || Array.isArray(value) || Object.keys(value).length));
3375
+ const group = container.closest("[data-field-group]");
3376
+ const active = Boolean(container.closest(".object-array-item")) || group?.dataset.required === "true" || hasValue;
3377
+ for (const field of fields) {
3378
+ const allowedWhen = field.dataset.objectAllowedWhen ? JSON.parse(field.dataset.objectAllowedWhen) : null;
3379
+ const requiredWhen = field.dataset.objectRequiredWhen ? JSON.parse(field.dataset.objectRequiredWhen) : null;
3380
+ field.hidden = Boolean(allowedWhen) && !conditionMatches(values, allowedWhen);
3381
+ const required = active && !field.hidden && (field.dataset.objectRequired === "true") && (!requiredWhen || conditionMatches(values, requiredWhen));
3382
+ field.querySelector(".required-mark")?.toggleAttribute("hidden", !required);
3383
+ const checkboxes = [...field.querySelectorAll(":scope > .checkbox-list input[type=checkbox]")];
3384
+ if (checkboxes.length) {
3385
+ const selected = checkboxes.findIndex((checkbox) => checkbox.checked);
3386
+ checkboxes.forEach((checkbox, index) => { checkbox.required = required && index === (selected < 0 ? 0 : selected); });
3387
+ } else {
3388
+ const map = field.querySelector(":scope > .string-map-editor");
3389
+ if (map) {
3390
+ [...map.querySelectorAll(".string-map-row")].forEach((row, index) => {
3391
+ const key = row.querySelector("[data-map-key]");
3392
+ const value = row.querySelector("[data-map-value]");
3393
+ const rowRequired = required && index === 0 || key.value.trim() || value.value.trim();
3394
+ key.required = Boolean(rowRequired);
3395
+ value.required = Boolean(rowRequired);
3396
+ });
3397
+ continue;
3398
+ }
3399
+ const control = field.querySelector(":scope > input, :scope > select, :scope > textarea");
3400
+ if (control) control.required = required;
3401
+ }
3402
+ }
3403
+ };
3404
+ dialog.querySelectorAll(".structured-object-fields").forEach(refresh);
3405
+ dialog.querySelector("form").addEventListener("input", (event) => {
3406
+ const container = event.target.closest(".structured-object-fields");
3407
+ if (container) refresh(container);
3408
+ });
3409
+ dialog.querySelector("form").addEventListener("change", (event) => {
3410
+ const container = event.target.closest(".structured-object-fields");
3411
+ if (container) refresh(container);
3412
+ });
3413
+ }
3414
+
3415
+ function readObjectProperty(field) {
3416
+ if (field.hidden) return undefined;
3417
+ const kind = field.dataset.objectKind;
3418
+ if (kind === "array") {
3419
+ const checkboxes = [...field.querySelectorAll(":scope > .checkbox-list input[type=checkbox]")];
3420
+ if (checkboxes.length) return checkboxes.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);
3421
+ return (field.querySelector(":scope > textarea")?.value || "").split(/\n|,/).map((item) => item.trim()).filter(Boolean);
3422
+ }
3423
+ if (kind === "object") {
3424
+ const nested = field.querySelector(":scope > .structured-object-fields");
3425
+ if (nested) return readStructuredObject(nested);
3426
+ const map = field.querySelector(":scope > .string-map-editor");
3427
+ if (map) return readStringMap(map);
3428
+ const raw = field.querySelector(":scope > textarea")?.value.trim();
3429
+ return raw ? JSON.parse(raw) : undefined;
3430
+ }
3431
+ const raw = field.querySelector(":scope > input, :scope > select, :scope > textarea")?.value ?? "";
3432
+ if (raw === "") return undefined;
3433
+ if (kind === "boolean") return raw === "true";
3434
+ if (["integer", "number"].includes(kind)) return Number(raw);
3435
+ if (kind === "timestamp") return new Date(raw).toISOString();
3436
+ return raw;
3437
+ }
3438
+
3439
+ function readStringMap(container) {
3440
+ return Object.fromEntries([...container.querySelectorAll(".string-map-row")]
3441
+ .map((row) => [row.querySelector("[data-map-key]").value.trim(), row.querySelector("[data-map-value]").value.trim()])
3442
+ .filter(([key, value]) => key && value));
3443
+ }
3444
+
3445
+ function readStructuredObject(container) {
3446
+ return Object.fromEntries([...container.querySelectorAll(":scope > [data-object-field]")]
3447
+ .map((field) => [field.dataset.objectField, readObjectProperty(field)])
3448
+ .filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length) && (typeof value !== "object" || Array.isArray(value) || Object.keys(value).length)));
3449
+ }
3450
+
3082
3451
  function fieldWrap(name, kind, label, requiredMark, control, help, required) {
3083
3452
  const labelId = "field-label-" + name;
3084
3453
  let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
@@ -3204,18 +3573,28 @@ function readGuidedRecord(dialog, base, fields) {
3204
3573
  }
3205
3574
  const kind = group.dataset.kind;
3206
3575
  let value;
3207
- if (kind === "relation-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
3576
+ if (kind === "relation-array" || kind === "choice-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
3208
3577
  else {
3209
3578
  const control = group.querySelector("input,select,textarea");
3210
3579
  const raw = control?.value ?? "";
3211
3580
  if (kind === "array") value = raw.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
3581
+ else if (kind === "object-array") {
3582
+ value = [...group.querySelectorAll(".object-array-items > .object-array-item")]
3583
+ .map((item) => readStructuredObject(item.querySelector(":scope > .structured-object-fields")))
3584
+ .filter((item) => Object.keys(item).length);
3585
+ }
3586
+ else if (kind === "structured-object") value = readStructuredObject(group.querySelector(":scope > .structured-object-fields"));
3212
3587
  else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
3213
3588
  else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
3214
3589
  else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
3215
3590
  else if (kind === "number") value = raw === "" ? undefined : Number(raw);
3216
3591
  else value = raw;
3217
3592
  }
3218
- if ((value === "" || value === undefined || (Array.isArray(value) && !value.length)) && group.dataset.required !== "true") delete record[name];
3593
+ const empty = value === ""
3594
+ || value === undefined
3595
+ || Array.isArray(value) && !value.length
3596
+ || value && typeof value === "object" && !Array.isArray(value) && !Object.keys(value).length;
3597
+ if (empty && group.dataset.required !== "true") delete record[name];
3219
3598
  else record[name] = value;
3220
3599
  }
3221
3600
  return record;
@@ -3225,8 +3604,20 @@ function relationCandidates(field) {
3225
3604
  return state.resources.filter(({ record }) => field.relation.includes("*") || field.relation.includes(record.type));
3226
3605
  }
3227
3606
 
3607
+ function relationTypeLabel(field, plural = false) {
3608
+ if (field.relation.includes("*")) return plural ? "Resources" : "resource";
3609
+ if (field.relation.length > 3) return plural ? "Resources" : "resource";
3610
+ const labels = field.relation.map((type) => (
3611
+ plural ? state.model.resources[type]?.pluralTitle : state.model.resources[type]?.title
3612
+ ) || humanize(type));
3613
+ if (labels.length < 2) return labels.join("");
3614
+ if (labels.length === 2) return labels.join(" or ");
3615
+ return labels.slice(0, -1).join(", ") + ", or " + labels.at(-1);
3616
+ }
3617
+
3228
3618
  function relationHelp(field) {
3229
3619
  if (field.relation.includes("*")) return "References any resource";
3620
+ if (field.relation.length > 3) return "References supported records";
3230
3621
  const labels = field.relation.map((type) => state.model.resources[type]?.pluralTitle || type);
3231
3622
  if (labels.length < 2) return "References " + labels.join("");
3232
3623
  if (labels.length === 2) return "References " + labels.join(" or ");
@@ -3326,7 +3717,14 @@ function globalSearch(query) {
3326
3717
  dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">Search</p><h2 id="search-results-title">' + esc(query) + '</h2></div><button class="icon-button" aria-label="Close">×</button></div><div class="result-list"></div><nav class="pagination search-pagination" aria-label="Search result pages" hidden><button class="button" type="button" data-search-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-search-page="next">Next</button></nav>';
3327
3718
  document.body.append(dialog);
3328
3719
  dialog.showModal();
3329
- dialog.querySelector(".icon-button").onclick = () => dialog.close();
3720
+ const clearSearch = () => {
3721
+ const search = root.querySelector("#global-search");
3722
+ if (search?.value.trim() === query) search.value = "";
3723
+ };
3724
+ dialog.querySelector(".icon-button").onclick = () => {
3725
+ clearSearch();
3726
+ dialog.close();
3727
+ };
3330
3728
  const results = dialog.querySelector(".result-list");
3331
3729
  const pagination = dialog.querySelector(".search-pagination");
3332
3730
  const previous = pagination.querySelector('[data-search-page="previous"]');
@@ -3337,7 +3735,10 @@ function globalSearch(query) {
3337
3735
  const start = (pageNumber - 1) * SEARCH_PAGE_SIZE;
3338
3736
  const visible = matches.slice(start, start + SEARCH_PAGE_SIZE);
3339
3737
  results.innerHTML = visible.length ? visible.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '"><strong>' + esc(record.title) + '</strong><small>' + esc(state.model.resources[record.type].title) + '</small></a>').join("") : empty("No matching records.");
3340
- results.querySelectorAll("a").forEach((link) => link.onclick = () => dialog.close());
3738
+ results.querySelectorAll("a").forEach((link) => link.onclick = () => {
3739
+ clearSearch();
3740
+ dialog.close();
3741
+ });
3341
3742
  pagination.hidden = totalPages === 1;
3342
3743
  previous.disabled = pageNumber === 1;
3343
3744
  next.disabled = pageNumber === totalPages;
@@ -3356,7 +3757,10 @@ function globalSearch(query) {
3356
3757
  results.scrollTop = 0;
3357
3758
  });
3358
3759
  renderResults();
3359
- dialog.addEventListener("close", () => dialog.remove());
3760
+ dialog.addEventListener("close", () => {
3761
+ clearSearch();
3762
+ dialog.remove();
3763
+ });
3360
3764
  }
3361
3765
 
3362
3766
  function auditProgress(audit) {
@@ -3531,9 +3935,15 @@ function conditionMatchesValues(condition, valueFor) {
3531
3935
  function conditionValueMatches(actual, expected) {
3532
3936
  return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
3533
3937
  }
3534
- function formatValue(value, field, type) {
3938
+ function formatValue(value, field, type, compact = false) {
3535
3939
  if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
3536
3940
  const definition = fieldDefinition(type, field);
3941
+ if (type === "program" && field === "requirementApplicability" && Array.isArray(value)) {
3942
+ const reviewed = value.filter(({ decision }) => ["applicable", "not-applicable"].includes(decision)).length;
3943
+ const pending = value.length - reviewed;
3944
+ const label = reviewed + " reviewed" + (pending ? " · " + pending + " to review" : "");
3945
+ return '<a class="tag relation" href="#/resources/requirement' + (pending ? "?review=1" : "") + '">' + esc(label) + '</a>';
3946
+ }
3537
3947
  if (definition?.type === "date") return esc(formatCalendarDate(value));
3538
3948
  if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
3539
3949
  if (type === "obligation" && field === "status") {
@@ -3541,7 +3951,12 @@ function formatValue(value, field, type) {
3541
3951
  return '<span class="badge ' + (value === "active" ? "neutral" : "status-" + esc(String(value))) + '">' + esc(label) + '</span>';
3542
3952
  }
3543
3953
  if (field === "status" || field.endsWith("Rating") || field === "severity" || field === "outcome") return '<span class="badge status-' + esc(String(value)) + '">' + esc(properCase(value)) + '</span>';
3544
- if (Array.isArray(value)) return value.length ? value.map((item) => typeof item === "object" ? '<code>' + esc(JSON.stringify(item)) + '</code>' : formatReference(item)).join(" ") : '<span class="muted">None</span>';
3954
+ if (Array.isArray(value) && definition?.items === "object") return formatObjectArray(value, definition.itemObjectType, compact);
3955
+ if (Array.isArray(value)) {
3956
+ if (!value.length) return '<span class="muted">None</span>';
3957
+ if (field === "evidenceSourceKinds") return value.map((item) => '<span class="tag choice-tag">' + esc(properCase(item)) + '</span>').join(" ");
3958
+ return value.map((item) => formatReference(item)).join(" ");
3959
+ }
3545
3960
  if (field === "sourceReference" && typeof value === "object") {
3546
3961
  const href = safeExternalUrl(value.url);
3547
3962
  if (href) return '<a class="external-source" href="' + esc(href) + '" target="_blank" rel="noopener noreferrer"><span><strong>' + esc(value.title || "Official source") + '</strong><small>' + esc(href) + '</small></span><b aria-hidden="true">↗</b></a>';
@@ -3553,6 +3968,33 @@ function formatValue(value, field, type) {
3553
3968
  if (definition?.type === "enum") return esc(properCase(value));
3554
3969
  return esc(String(value));
3555
3970
  }
3971
+
3972
+ function formatObjectArray(items, objectType, compact = false) {
3973
+ if (!items.length) return '<span class="muted">None</span>';
3974
+ const schema = state.model.objectTypes[objectType];
3975
+ if (!schema) return '<span class="muted">' + items.length + ' ' + pluralize("item", items.length) + '</span>';
3976
+ return '<div class="object-value-list ' + (compact ? "compact" : "") + '">' + items.map((item) => {
3977
+ const notes = [];
3978
+ const facts = Object.entries(schema.properties || {}).flatMap(([name, property]) => {
3979
+ const value = item[name];
3980
+ if (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length) return [];
3981
+ if (["rationale", "description", "notes"].includes(name)) {
3982
+ notes.push(String(value));
3983
+ return [];
3984
+ }
3985
+ const label = humanize(name.replace(/Ids?$/, ""));
3986
+ let display;
3987
+ if (property.relation && property.type === "id") display = formatReference(value);
3988
+ else if (property.relation && property.type === "array") display = value.map((id) => formatReference(id)).join(" ");
3989
+ else if (Array.isArray(value)) display = esc(value.map((entry) => properCase(entry)).join(", "));
3990
+ else if (property.type === "enum") display = esc(properCase(value));
3991
+ else if (property.type === "date") display = esc(formatCalendarDate(value));
3992
+ else display = esc(String(value));
3993
+ return ['<span><b>' + esc(label) + '</b>' + display + '</span>'];
3994
+ });
3995
+ return '<div class="object-value"><span class="object-value-facts">' + facts.join("") + '</span>' + (!compact && notes.length ? '<small>' + esc(notes.join(" ")) + '</small>' : "") + '</div>';
3996
+ }).join("") + '</div>';
3997
+ }
3556
3998
  function controlOperationTracking(control) {
3557
3999
  const obligations = resourcesOfType("obligation")
3558
4000
  .map(({ record }) => record)
@@ -3636,13 +4078,97 @@ function pluralize(noun, count) {
3636
4078
  if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + "ies";
3637
4079
  return noun + "s";
3638
4080
  }
3639
- function renderNotFound(main) { main.innerHTML = '<div class="page">' + empty("That resource does not exist.") + '</div>'; }
4081
+ function renderNotFound(main) {
4082
+ const route = parseRoute();
4083
+ const definition = route.type ? state.model.resources[route.type] : null;
4084
+ const recordMissing = route.name === "detail" && definition;
4085
+ const title = recordMissing ? definition.title + " Not Found" : "Page Not Found";
4086
+ const message = recordMissing ? "This record may have been renamed or deleted." : "This page does not exist.";
4087
+ const collectionLink = recordMissing
4088
+ ? '<a class="button primary" href="#/resources/' + encodeURIComponent(route.type) + '">Back to ' + esc(titleCase(definition.pluralTitle)) + '</a>'
4089
+ : "";
4090
+ main.innerHTML = '<div class="page"><section class="panel not-found"><p class="kicker">Not found</p><h2>' + esc(title) + '</h2><p>' + esc(message) + '</p><div class="page-actions">' + collectionLink + '<a class="button ' + (recordMissing ? "" : "primary") + '" href="#/">Program overview</a></div></section></div>';
4091
+ }
3640
4092
  function applyMutationState(result) {
3641
- if (!result?.state) throw new Error("The save response did not include the current workspace state.");
3642
- state = result.state;
4093
+ if (result?.state) {
4094
+ state = result.state;
4095
+ } else if (result?.stateRefresh) {
4096
+ applyFastMutationPatch(result);
4097
+ scheduleMutationStateRefresh();
4098
+ } else {
4099
+ throw new Error("The save response did not include the current workspace state.");
4100
+ }
3643
4101
  scheduleRepositorySyncPoll(result.synchronization);
3644
4102
  }
3645
4103
 
4104
+ function applyFastMutationPatch(result) {
4105
+ if (result.operation === "collection-review" && result.assessment?.resourceType) {
4106
+ state.collectionReviews[result.assessment.resourceType] = result.assessment;
4107
+ }
4108
+ for (const record of result.changes?.update || []) {
4109
+ const entry = state.resources.find(({ record: current }) => current.id === record.id);
4110
+ if (entry) entry.record = record;
4111
+ }
4112
+ for (const record of result.changes?.create || []) {
4113
+ if (!state.resources.some(({ record: current }) => current.id === record.id)) {
4114
+ state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
4115
+ }
4116
+ }
4117
+ if (result.synchronization) {
4118
+ state.repository = {
4119
+ ...state.repository,
4120
+ status: result.synchronization.status,
4121
+ label: result.synchronization.status === "syncing" ? "Syncing" : state.repository.label,
4122
+ currentCommit: result.synchronization.commit || state.repository.currentCommit,
4123
+ writesAllowed: result.synchronization.status !== "syncing",
4124
+ backgroundSynchronization: result.synchronization.status === "syncing" ? result.synchronization : null
4125
+ };
4126
+ }
4127
+ state.readOnly = true;
4128
+ }
4129
+
4130
+ function scheduleMutationStateRefresh(delay = 0) {
4131
+ if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
4132
+ mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
4133
+ }
4134
+
4135
+ async function refreshMutationState() {
4136
+ mutationStateRefreshTimer = null;
4137
+ if (mutationStateRefreshInFlight) return;
4138
+ mutationStateRefreshInFlight = true;
4139
+ let retry = false;
4140
+ try {
4141
+ const response = await fetch("/api/state");
4142
+ if (!response.ok) throw new Error(await responseMessage(response));
4143
+ state = await response.json();
4144
+ render();
4145
+ scheduleRepositorySyncPoll();
4146
+ } catch {
4147
+ retry = true;
4148
+ } finally {
4149
+ mutationStateRefreshInFlight = false;
4150
+ }
4151
+ if (retry) scheduleMutationStateRefresh(1_000);
4152
+ }
4153
+
4154
+ function prefetchRepositoryForReview(status) {
4155
+ if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
4156
+ return Promise.resolve(null);
4157
+ }
4158
+ status.textContent = "Checking repository…";
4159
+ return fetch("/api/git/prefetch", { method: "POST" })
4160
+ .then(async (response) => {
4161
+ if (!response.ok) throw new Error(await responseMessage(response));
4162
+ const result = await response.json();
4163
+ status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
4164
+ return result;
4165
+ })
4166
+ .catch(() => {
4167
+ status.textContent = "Repository will be checked when you save";
4168
+ return null;
4169
+ });
4170
+ }
4171
+
3646
4172
  function scheduleRepositorySyncPoll(synchronization = state.repository?.backgroundSynchronization) {
3647
4173
  const syncing = synchronization?.status === "syncing"
3648
4174
  || state.repository?.status === "syncing";
@@ -3717,6 +4243,30 @@ function showError(message) {
3717
4243
  dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
3718
4244
  dialog.addEventListener("close", () => dialog.remove());
3719
4245
  }
4246
+
4247
+ function confirmAction({ kicker, title, message, confirmLabel = "Confirm", danger = false }) {
4248
+ const dialog = document.createElement("dialog");
4249
+ dialog.className = "alert-dialog confirmation-dialog";
4250
+ dialog.setAttribute("aria-labelledby", "confirmation-dialog-title");
4251
+ dialog.setAttribute("aria-describedby", "confirmation-dialog-message");
4252
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + esc(kicker) + '</p><h2 id="confirmation-dialog-title">' + esc(title) + '</h2></div><button type="button" class="icon-button" data-confirm-dismiss aria-label="Close">×</button></div><p id="confirmation-dialog-message">' + esc(message) + '</p><div class="dialog-actions"><button type="button" class="button" data-confirm-dismiss>Cancel</button><button type="submit" class="button ' + (danger ? "danger-action" : "primary") + '">' + esc(confirmLabel) + '</button></div></form>';
4253
+ document.body.append(dialog);
4254
+ return new Promise((resolve) => {
4255
+ let confirmed = false;
4256
+ dialog.querySelector("form").addEventListener("submit", (event) => {
4257
+ event.preventDefault();
4258
+ confirmed = true;
4259
+ dialog.close();
4260
+ });
4261
+ dialog.querySelectorAll("[data-confirm-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
4262
+ dialog.addEventListener("close", () => {
4263
+ dialog.remove();
4264
+ resolve(confirmed);
4265
+ }, { once: true });
4266
+ dialog.showModal();
4267
+ });
4268
+ }
4269
+
3720
4270
  async function responseMessage(response) {
3721
4271
  const source = await response.text();
3722
4272
  try { return JSON.parse(source).error || source; } catch { return source; }
@@ -3725,7 +4275,7 @@ async function localFetch(url, options) {
3725
4275
  const method = String(options?.method || "GET").toUpperCase();
3726
4276
  const synchronizing = state?.repository?.mode === "trunk"
3727
4277
  && ["POST", "PUT", "DELETE"].includes(method)
3728
- && url !== "/api/evidence-packet";
4278
+ && !["/api/evidence-packet", "/api/git/prefetch"].includes(url);
3729
4279
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
3730
4280
  const previousChip = chip?.innerHTML;
3731
4281
  let repositoryRefreshed = false;
@@ -3770,7 +4320,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
3770
4320
  .nav-stage>.nav-items>a.nav-direct{display:grid;grid-template-columns:minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;width:100%;padding:6px 6px 6px 7px;font-size:13.2px}
3771
4321
  .nav-heading-row{display:grid;grid-template-columns:minmax(0,1fr) 24px;gap:2px;align-items:stretch}.nav-heading-row>.nav-heading{display:grid;grid-template-columns:24px minmax(0,1fr);gap:6px;align-items:center;width:100%;padding:7px 6px;border-radius:7px;color:#d5d9ed;text-align:left;text-transform:none;letter-spacing:0;text-decoration:none}.nav-heading-row>.nav-heading:hover,.nav-heading-row>.nav-heading.current{background:rgba(255,255,255,.1);color:#fff}.nav-toggle{display:grid;place-items:center;width:24px;height:auto;min-height:100%;padding:0;border:0;border-radius:6px;background:none;color:#aeb6d8;cursor:pointer}.nav-toggle:hover{background:rgba(255,255,255,.1);color:#fff}.nav-chevron{display:block;width:12px;height:12px;place-self:center;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}.nav-subheading-row{display:grid;grid-template-columns:minmax(0,1fr) 22px;gap:2px;align-items:center;width:100%;padding:0;border:0;border-radius:6px;background:none;color:#919bc4;cursor:pointer}.nav-subheading-row:hover{background:rgba(255,255,255,.1);color:#fff}.nav-subheading-row>.nav-subheading{display:flex;align-items:center;min-width:0;padding:7px;color:inherit;text-align:left;text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780}.nav-group.open>.nav-heading-row .nav-chevron,.nav-group.open>.nav-subheading-row .nav-chevron{transform:rotate(90deg)}
3772
4322
  .stage-overview-hero{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:28px;align-items:center;padding:26px 28px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stage-overview-hero h2,.group-overview-head h2{font:500 37.2px Georgia,serif;margin:7px 0}.stage-overview-hero>div>p:not(.kicker),.group-overview-head>div>p:not(.kicker){max-width:760px;color:var(--muted);font-size:14.4px;line-height:1.55;margin:0}.stage-progress-card{padding:15px 17px;background:var(--paper);border:1px solid var(--line);border-radius:9px}.stage-progress-card>div:first-child{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}.stage-progress-card>div:first-child>strong{font:500 33.6px Georgia,serif}.stage-progress-card p{color:var(--muted);font-size:10.8px;line-height:1.45;margin:9px 0 0}.badge.neutral{background:#e5e8f2;color:#555e73}.stage-overview-layout{display:grid;grid-template-columns:320px minmax(0,1fr);gap:15px;margin-top:15px;align-items:start}.stage-plan ol,.group-plan ol{display:grid;gap:12px;padding-left:20px;margin:0}.stage-plan li,.group-plan li{padding-left:4px;color:var(--ink);font-size:13.2px;line-height:1.5}.stage-groups{min-width:0}.stage-groups>.section-head{margin:4px 0 13px}.stage-group-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-group-card{position:relative;display:block;min-height:138px;padding:18px 38px 17px 18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.stage-group-card:hover{border-color:var(--accent-light)}.stage-group-card h3{font-size:15.6px;margin:0 0 7px}.stage-group-card p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.stage-group-card small{display:block;color:var(--accent);font-size:9.6px;font-weight:700;margin-top:12px}.stage-group-arrow{position:absolute;right:16px;top:16px;color:var(--accent);font-size:24px}.group-overview-head{display:flex;justify-content:space-between;align-items:end;gap:25px;margin-bottom:15px}.stage-status-link{display:grid;grid-template-columns:auto auto;align-items:center;gap:4px 12px;min-width:155px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);border-radius:9px;text-decoration:none}.stage-status-link>strong{font:500 28.8px Georgia,serif;text-align:right}.stage-status-link>small{grid-column:1/-1;color:var(--muted);font-size:9.6px;text-align:right}.relationship-note{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;align-items:center;margin-bottom:15px;padding:17px 20px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.relationship-note h3{font-size:15.6px;margin:5px 0 0}.relationship-note>p{color:var(--muted);font-size:12px;line-height:1.55;margin:0}.relationship-note code{font-size:10.8px}.group-plan{margin-bottom:24px}.group-related-links{display:flex;align-items:center;gap:8px;margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}.group-related-links>span{color:var(--muted);font-size:10.8px;margin-right:auto}.group-destinations>.section-head{margin-bottom:12px}.group-destination-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.group-destination-card{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:16px;min-height:150px;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.group-destination-card:hover{border-color:var(--accent-light)}.group-destination-card h3{font-size:15.6px;margin:5px 0 7px}.group-destination-card p:not(.kicker){color:var(--muted);font-size:10.8px;line-height:1.5;margin:0}.destination-rollup{align-self:center;text-align:right}.destination-rollup strong,.destination-rollup small{display:block}.destination-rollup strong{font:500 32.4px Georgia,serif}.destination-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:3px}
3773
- .collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
4323
+ .collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.review-save-status{margin-right:auto;color:var(--muted);font-size:11px}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
3774
4324
  .workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
3775
4325
  .evidence-attachments .panel-head{align-items:flex-start}.evidence-attachments .panel-head h3{margin:0}.evidence-attachments .panel-head p{margin:4px 0 0;color:var(--muted);font-size:10px}.evidence-attachments ul{list-style:none;margin:0;padding:0}.evidence-attachments li{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line)}.evidence-attachments li:first-child{border-top:0}.evidence-attachments strong,.evidence-attachments small{display:block}.evidence-attachments strong{font-size:11px}.evidence-attachments small{margin-top:2px;color:var(--muted);font-size:9px;overflow-wrap:anywhere}.evidence-attachments .attachment-empty{display:block;color:var(--muted);font-size:10px;line-height:1.45}.danger-text{color:var(--red)}
3776
4326
  .button{text-decoration:none}
@@ -3780,26 +4330,28 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
3780
4330
  .overview-grid>.panel{padding:15px}.overview-grid .panel-head{margin-bottom:10px}.overview-grid .audit-progress{gap:7px 20px}.overview-grid .progress-number strong{font-size:31.2px}.overview-grid .audit-engagement{padding:8px 11px}
3781
4331
  .nav-close,.nav-scrim{display:none}.pagination{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px}.pagination[hidden]{display:none}.page-status{color:var(--muted);font-size:12px;min-width:150px;text-align:center}.button:disabled{cursor:not-allowed;opacity:.45}.search-pagination{padding-top:2px}
3782
4332
  .list-tools{flex-wrap:wrap}.list-tools label{min-width:220px}.list-header-tools{flex:1;justify-content:flex-end;margin:0 0 0 28px}.list-header-tools label{flex:1 1 220px;max-width:360px}.list-header-tools select{max-width:190px}.list-header-tools .button{white-space:nowrap}
3783
- .setup-banner{margin:14px 0;background:#eef1ff;border:1px solid #ccd4ff;border-radius:11px;padding:19px 22px;display:grid;grid-template-columns:1fr 1.3fr;gap:25px;align-items:center}.setup-banner h3{margin:5px 0 6px;font-size:18px}.setup-banner p:not(.kicker){margin:0;color:var(--muted);font-size:13.2px;line-height:1.5}.setup-banner ol{margin:0;padding-left:22px;display:grid;gap:7px}.setup-banner li{font-size:13.2px}.setup-banner a{color:var(--accent);font-weight:700}.due-list time.overdue{color:var(--red)}.content-label{display:flex;align-items:center;justify-content:space-between;gap:12px}.text-button{border:0;background:none;color:var(--accent);font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;font-weight:750;cursor:pointer;white-space:nowrap}.tag{white-space:normal;overflow-wrap:anywhere;max-width:100%}.editor{max-height:calc(100vh - 30px);overflow:auto}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.form-field>.field-label,.content-editor-field>span{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#3e4557;font-size:12px;font-weight:700;margin-bottom:6px}.required-mark{font-size:9.6px;color:var(--accent);text-transform:uppercase;letter-spacing:.06em}.form-field input,.form-field select,.editor .form-field textarea{width:100%;height:auto;min-height:40px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);padding:9px 10px;font:14.4px/1.4 inherit}.editor .form-field textarea{height:82px}.form-field input[readonly]{background:#eef0f6;color:#5d6475}.form-field>small{display:block;color:#6a7181;font-size:10.8px;margin-top:5px}.checkbox-list{display:grid;gap:5px;max-height:145px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:7px}.checkbox-list label{display:flex;align-items:center;gap:8px;padding:5px;border-radius:5px}.checkbox-list input{width:16px;min-height:16px;padding:0;flex:0 0 auto}.checkbox-list label:hover{background:#f2f4fa}.checkbox-list span,.checkbox-list small{display:block;font-size:12px}.checkbox-list small{color:var(--muted);margin-top:2px}.missing-options{padding:11px;border:1px dashed #d7c8a9;background:#fbf5e9;color:#795b23;border-radius:7px;font-size:12px}.content-editor-field{display:block;margin:17px 0}.editor .content-editor-field textarea,.editor .markdown-source{height:260px;background:#10162b;color:#e8ebff;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.advanced-editor{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.advanced-editor summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.advanced-editor p{font-size:12px;color:var(--muted)}.editor .advanced-editor>textarea{height:320px}.alert-dialog{width:min(520px,calc(100vw - 30px));border:0;border-radius:12px;padding:23px;box-shadow:0 25px 80px rgba(0,0,24,.28)}.alert-dialog>p{font-size:14.4px;line-height:1.55;color:var(--muted)}.metadata dd{overflow-wrap:anywhere}
4333
+ .setup-banner{margin:14px 0;background:#eef1ff;border:1px solid #ccd4ff;border-radius:11px;padding:19px 22px;display:grid;grid-template-columns:1fr auto;gap:25px;align-items:center}.setup-banner h3{margin:5px 0 6px;font-size:18px}.setup-banner p:not(.kicker){margin:0;color:var(--muted);font-size:13.2px;line-height:1.5}.setup-banner>.button{justify-self:end}.setup-draft-state{display:flex;align-items:center;justify-content:flex-end;gap:22px}.setup-draft-state span{min-width:100px}.setup-draft-state small,.setup-draft-state strong{display:block}.setup-draft-state small{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.setup-draft-state strong{margin-top:3px;font-size:12px}.due-list time.overdue{color:var(--red)}.content-label{display:flex;align-items:center;justify-content:space-between;gap:12px}.text-button{border:0;background:none;color:var(--accent);font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;font-weight:750;cursor:pointer;white-space:nowrap}.tag{white-space:normal;overflow-wrap:anywhere;max-width:100%}.editor{max-height:calc(100vh - 30px);overflow:auto}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.form-field>.field-label,.content-editor-field>span{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#3e4557;font-size:12px;font-weight:700;margin-bottom:6px}.required-mark{font-size:9.6px;color:var(--accent);text-transform:uppercase;letter-spacing:.06em}.form-field input,.form-field select,.editor .form-field textarea{width:100%;height:auto;min-height:40px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);padding:9px 10px;font:14.4px/1.4 inherit}.editor .form-field textarea{height:82px}.form-field input[readonly]{background:#eef0f6;color:#5d6475}.form-field>small{display:block;color:#6a7181;font-size:10.8px;margin-top:5px}.checkbox-list{display:grid;gap:5px;max-height:145px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:7px}.checkbox-list label{display:flex;align-items:center;gap:8px;padding:5px;border-radius:5px}.checkbox-list input{width:16px;min-height:16px;padding:0;flex:0 0 auto}.checkbox-list label:hover{background:#f2f4fa}.checkbox-list span,.checkbox-list small{display:block;font-size:12px}.checkbox-list small{color:var(--muted);margin-top:2px}.choice-picker{border:1px solid var(--line);border-radius:7px;background:var(--field)}.choice-picker summary{min-height:40px;padding:10px;cursor:pointer;font-size:13.2px}.choice-picker .checkbox-list{max-height:220px;border:0;border-top:1px solid var(--line);border-radius:0}.structured-object-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.structured-object-fields.nested{grid-column:1/-1;background:var(--panel)}.object-property{min-width:0}.object-property-label{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:4px;font-size:11px;font-weight:700}.string-map-editor,.string-map-items{display:grid;gap:7px}.string-map-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:6px;align-items:center}.object-array-items{display:grid;gap:10px;margin-bottom:8px}.object-array-item{display:grid;gap:9px;border:1px solid var(--line);border-radius:8px;padding:10px}.object-array-item legend{padding:0 5px;color:var(--muted);font-size:11px;font-weight:750}.object-array-item>.structured-object-fields{padding:0;border:0;background:none}.missing-options{padding:11px;border:1px dashed #d7c8a9;background:#fbf5e9;color:#795b23;border-radius:7px;font-size:12px}.content-editor-field{display:block;margin:17px 0}.editor .content-editor-field textarea,.editor .markdown-source{height:260px;background:#10162b;color:#e8ebff;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.advanced-editor{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.advanced-editor summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.advanced-editor p{font-size:12px;color:var(--muted)}.editor .advanced-editor>textarea{height:320px}.alert-dialog{width:min(520px,calc(100vw - 30px));border:0;border-radius:12px;padding:23px;box-shadow:0 25px 80px rgba(0,0,24,.28)}.alert-dialog>p{font-size:14.4px;line-height:1.55;color:var(--muted)}.metadata dd{overflow-wrap:anywhere}
3784
4334
  .record-content-action{display:flex;justify-content:flex-start;margin-top:20px}.record-content-details{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.record-content-details summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.record-content-details>p{color:var(--muted);font-size:12px}.record-content-editor>span small{color:var(--muted);font-size:10.8px;font-weight:500}
3785
4335
  .program-setup{grid-template-columns:minmax(250px,.75fr) minmax(440px,1.4fr)}.setup-steps{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.setup-steps a{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;align-items:start;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);text-decoration:none}.setup-steps a:hover{border-color:var(--accent-light)}.setup-steps a>span:first-child{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--accent-soft);color:var(--accent);font-size:12px}.setup-steps a.done>span:first-child{background:#dcefe4;color:#125733}.setup-steps strong,.setup-steps small{display:block}.setup-steps strong{font-size:12px}.setup-steps small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4;font-weight:500}
3786
- .readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
4336
+ .readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:3px 8px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary>div>.badge{font-size:8.4px}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
3787
4337
  .page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.page-guide>.guide-review{grid-column:1/-1;border-top:1px solid var(--line);border-left:0}.guide-review ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:0;padding-left:18px;color:var(--muted);font-size:11px;line-height:1.45}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
3788
4338
  .operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
3789
4339
  .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
3790
4340
  .onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
3791
4341
  .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
3792
4342
  .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
4343
+ .not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
4344
+ .button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
3793
4345
  @media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
3794
4346
  @media(max-width:1100px){.search{display:none}.topbar-status{margin-left:auto}.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.organization-grid{grid-template-columns:repeat(2,1fr)}.catalog{grid-template-columns:repeat(3,1fr)}.span-2{grid-column:span 2}.resource-directory{grid-template-columns:repeat(2,minmax(0,1fr))}}
3795
- @media(max-width:760px){.shell{display:block}.sidebar{transform:translateX(-100%);transition:.2s;box-shadow:8px 0 30px rgba(0,0,0,.2)}.sidebar.shown{transform:translateX(0)}.workspace{min-width:0}.mobile-nav{display:block;border:0;background:none;font-size:24px}.topbar{height:72px;padding:0 16px}.topbar>div:first-of-type{min-width:0}.topbar-status{display:none}.search{display:flex;max-width:none}.search kbd,.topbar .eyebrow{display:none}.page{padding:20px 15px 60px}.hero{display:block;padding:23px}.hero-meta{margin-top:22px;flex-wrap:wrap}.metrics,.dashboard-grid,.organization-grid{grid-template-columns:1fr}.span-2{grid-column:auto}.catalog{grid-template-columns:repeat(2,1fr)}.detail-grid{grid-template-columns:1fr}.page-intro,.detail-head{display:block}.page-intro>.button,.actions{margin-top:15px}.page-intro>.list-header-tools{justify-content:flex-start;margin:15px 0 0}.list-header-tools label{max-width:none}.record-table{min-width:720px}.readiness-map{padding:17px}.readiness-map-head{grid-template-columns:1fr;gap:8px}.readiness-flow{grid-template-columns:repeat(2,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr}.audit-engagement .button{grid-column:auto}.resource-directory{grid-template-columns:1fr}}
4347
+ @media(max-width:760px){.shell{display:block}.sidebar{transform:translateX(-100%);transition:.2s;box-shadow:8px 0 30px rgba(0,0,0,.2)}.sidebar.shown{transform:translateX(0)}.workspace{min-width:0}.mobile-nav{display:block;border:0;background:none;font-size:24px}.topbar{height:72px;padding:0 16px}.topbar>div:first-of-type{flex:1;min-width:0}.topbar h1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.topbar-status{display:none}.search{display:flex;flex:0 1 180px;min-width:110px;max-width:none}.search kbd,.topbar .eyebrow{display:none}.page{padding:20px 15px 60px}.hero{display:block;padding:23px}.hero-meta{margin-top:22px;flex-wrap:wrap}.metrics,.dashboard-grid,.organization-grid{grid-template-columns:1fr}.span-2{grid-column:auto}.catalog{grid-template-columns:repeat(2,1fr)}.detail-grid{grid-template-columns:1fr}.page-intro,.detail-head{display:block}.page-intro>.button,.actions{margin-top:15px}.page-intro>.list-header-tools{justify-content:flex-start;margin:15px 0 0}.list-header-tools label{max-width:none}.record-table{min-width:720px}.readiness-map{padding:17px}.readiness-map-head{grid-template-columns:1fr;gap:8px}.readiness-flow{grid-template-columns:repeat(2,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr}.audit-engagement .button{grid-column:auto}.resource-directory{grid-template-columns:1fr}}
3796
4348
  @media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid,.evidence-map-expectation,.evidence-map-links,.external-evidence-list{grid-template-columns:1fr}.context-workflow,.evidence-map-head{align-items:stretch;flex-direction:column}.evidence-map-actions{flex-wrap:wrap}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}}
3797
4349
  @media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
3798
4350
  @media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
3799
4351
  @media(min-width:761px){.detail-grid{grid-template-columns:minmax(270px,1fr) minmax(0,2fr)}.detail-grid aside{grid-column:1;grid-row:1}.detail-main{grid-column:2;grid-row:1}}
3800
4352
  @media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.detail-grid-structured aside>.panel{align-self:start}}
3801
4353
  @media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
3802
- @media(max-width:760px){.topbar{height:56px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
4354
+ @media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.search{flex:1;min-width:0}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
3803
4355
 
3804
4356
  .connection-group+.connection-group{margin-top:15px;padding-top:13px;border-top:1px solid var(--line)}.connection-group h4{margin:0 0 8px;color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.08em}
3805
4357
  body,button,input,select,textarea,dialog{color:var(--ink)}
@@ -3874,6 +4426,8 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
3874
4426
  .obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px}
3875
4427
  .event-dialog label{display:block;margin-top:13px}.event-dialog label[hidden]{display:none}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select,.event-dialog textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog textarea{resize:vertical}.commit-dialog .form-grid label.full{grid-column:1/-1}.workflow-preview{margin-top:14px;padding:0 12px;border-radius:7px;background:var(--surface-soft)}.workflow-preview:not(:empty){padding-top:10px;padding-bottom:10px}.workflow-preview strong,.workflow-preview p{display:block;margin:0}.workflow-preview p{margin-top:5px;color:var(--muted);font-size:12px;line-height:1.5}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
3876
4428
  .applicability-dialog{width:min(980px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:0;border-radius:12px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 25px 80px rgba(0,0,24,.28)}.applicability-dialog form{padding:23px}.applicability-dialog form>p{color:var(--muted);font-size:13.2px}.review-context label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.review-context input,.review-context select,.applicability-row input,.applicability-row select{width:100%;min-height:38px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:8px 9px;font-size:13.2px}.review-context label.full{grid-column:1/-1}.applicability-rows{display:grid;gap:7px;max-height:46vh;overflow:auto;margin-top:16px;padding-right:4px}.applicability-row{display:grid;grid-template-columns:minmax(210px,1fr) 180px minmax(240px,1.3fr);gap:9px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:8px}.applicability-row strong,.applicability-row small{display:block}.applicability-row small{margin-top:3px;color:var(--muted);font-size:10.8px}
4429
+ .object-value-list{display:grid;gap:7px}.object-value{min-width:0;padding:7px 8px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.object-value-facts{display:flex;flex-wrap:wrap;gap:5px 10px}.object-value-facts>span{display:flex;align-items:baseline;gap:5px;min-width:0;font-size:11px}.object-value-facts b{color:var(--muted);font-size:8.8px;text-transform:uppercase;letter-spacing:.05em}.object-value>small{display:block;margin-top:6px;color:var(--muted);font-size:10px;line-height:1.45}.object-value-list.compact .object-value{padding:0;border:0;background:none}.object-value-list.compact .object-value-facts{display:grid;gap:5px}.object-value-list.compact .object-value-facts>span{display:grid;gap:2px;font-size:10px}
4430
+ .resource-review-criteria.compact{margin:12px 0;padding:10px 12px;background:var(--surface-soft)}.resource-review-criteria.compact summary{cursor:pointer;font-size:11px;font-weight:750}.resource-review-criteria.compact ul{margin-bottom:0}.evidence-map-more{justify-self:center;margin:2px 0 8px}
3877
4431
  .packet-builder form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:12px;align-items:end}.packet-builder label>span{display:block;font-size:10.8px;font-weight:720;margin-bottom:6px}.packet-builder input,.packet-builder select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:13.2px}.packet-note,.packet-output>p{font-size:12px;color:var(--muted);margin:12px 0 0}.packet-output{margin:14px 0}.packet-output h3{overflow-wrap:anywhere}.packet-gaps{display:grid}.packet-gaps>div{display:grid;grid-template-columns:58px 1fr;gap:10px;border-top:1px solid var(--line);padding:10px 0}.packet-gaps>div:first-child{border-top:0}.packet-gaps p{font-size:12px;margin:0}.packet-list{display:grid}.packet-list a{display:block;text-decoration:none;border-top:1px solid var(--line);padding:9px 0}.packet-list a:first-child{border-top:0}.packet-list strong,.packet-list small{display:block}.packet-list strong{font-size:12px}.packet-list small{font-size:9.6px;color:var(--muted);margin-top:2px}
3878
4432
  .packet-preflight{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-bottom:12px}.packet-preflight a{display:flex;align-items:flex-start;gap:9px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);text-decoration:none}.packet-preflight .status-dot{margin-top:4px}.packet-preflight small,.packet-preflight strong{display:block}.packet-preflight small{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.07em}.packet-preflight strong{margin-top:3px;font-size:12px}.audit-evidence-paths{margin-bottom:12px}.audit-evidence-paths .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.audit-evidence-path-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.audit-evidence-path-grid>a{display:block;padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.audit-evidence-path-grid h4{margin:7px 0 5px;font-size:13.2px}.audit-evidence-path-grid p{margin:0;color:var(--muted);font-size:10.8px;line-height:1.55}
3879
4433
  .audit-preparation{margin-bottom:12px}.audit-preparation .panel-head{align-items:flex-start}.audit-preparation .panel-head h3{margin:3px 0}.audit-preparation .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.preparation-progress{height:5px;margin:12px 0 0;border-radius:99px;background:var(--surface-muted);overflow:hidden}.preparation-progress span{display:block;height:100%;border-radius:inherit;background:var(--primary-gradient)}.audit-preparation-note{margin:9px 0 0;color:var(--muted);font-size:10.8px;line-height:1.5}.preparation-stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.preparation-stage{border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);overflow:hidden}.preparation-stage summary{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:11px 12px;cursor:pointer;list-style:none}.preparation-stage summary::-webkit-details-marker{display:none}.preparation-stage summary span,.preparation-stage summary strong,.preparation-stage summary small{display:block}.preparation-stage summary strong{font-size:12px}.preparation-stage summary small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.preparation-stage summary b{flex:none;color:var(--muted);font-size:9.6px;font-weight:650}.preparation-items{border-top:1px solid var(--line);background:var(--panel)}.preparation-items>a,.preparation-items>div{display:grid;grid-template-columns:22px minmax(0,1fr);gap:9px;padding:10px 12px;border-top:1px solid var(--line);text-decoration:none}.preparation-items>:first-child{border-top:0}.preparation-items strong,.preparation-items small{display:block}.preparation-items strong{font-size:10.8px}.preparation-items small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.45}.preparation-status{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--surface-muted);color:var(--muted);font-size:10.8px;font-weight:800}.preparation-status.complete{background:#dcefe4;color:#125733}.preparation-status.action{background:#f7dfdc;color:#873027}.preparation-status.later{background:#f6e8c9;color:#79500f}.preparation-status.external,.preparation-status.info{background:var(--accent-soft);color:var(--accent)}.audit-preparation-error:empty{display:none}
@@ -3883,7 +4437,11 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
3883
4437
  @media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
3884
4438
  @media(max-width:520px){.event-reminder-preview,.policy-event-list,.packet-builder form,.packet-preflight,.audit-evidence-path-grid{grid-template-columns:1fr}.policy-event-row:nth-child(n) .policy-event-tooltip{right:auto;left:0}.packet-metrics{grid-template-columns:1fr}.obligation-card-head{display:block}.obligation-card-head strong{display:block;text-align:left;margin-top:3px}}
3885
4439
  @media(max-width:520px){.policy-event-feedback{grid-template-columns:auto minmax(0,1fr) auto}.policy-event-feedback>.button{grid-column:2}.policy-event-feedback>.icon-button{grid-column:3;grid-row:1}}
4440
+ @media(max-width:520px){.applicability-dialog form{padding:18px}.applicability-row{grid-template-columns:1fr}.applicability-rows{max-height:42vh}}
3886
4441
  @media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
4442
+ .choice-tag{overflow-wrap:normal;font-size:10px}.form-field[data-kind="structured-object"],.string-map-property{grid-column:1/-1}.string-map-row .text-button{text-transform:none;letter-spacing:0;color:var(--muted);font-size:11px}
4443
+ @media(max-width:760px){.setup-banner>.button{justify-self:start}.setup-draft-state{justify-content:flex-start;flex-wrap:wrap;gap:12px 22px}.setup-draft-state .button{width:100%}}
4444
+ @media(max-width:760px){.string-map-row{grid-template-columns:1fr 1fr}.string-map-row .text-button{grid-column:1/-1;justify-self:start}}
3887
4445
 
3888
4446
  @media(prefers-color-scheme:dark){
3889
4447
  :root{--ink:#f4f5ff;--muted:#b8bfd3;--line:#343d5c;--paper:#000;--panel:#141a2e;--accent:#aab7ff;--accent-soft:#252e52;--accent-light:#9aabff;--focus:#bdc7ff;--amber:#ffd08a;--red:#ffaaa0;--surface-soft:#1b2238;--surface-muted:#252d48;--field:#11172a;--field-readonly:#1c2338;--shadow:0 12px 34px rgba(0,0,0,.3)}