filegrc 0.5.1 → 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
@@ -81,12 +81,15 @@ let onboardingShade = null;
81
81
  let onboardingStep = 0;
82
82
  let onboardingDraft = null;
83
83
  let onboardingBusy = false;
84
+ let onboardingSetupOnly = false;
84
85
  let onboardingStillWorkingTimer = null;
85
86
  let onboardingPendingDraft = false;
86
87
  const resourceDetailRequests = new Map();
87
88
  let resourceGuideCleanup = null;
88
89
  let repositorySyncPollTimer = null;
89
90
  let repositorySyncPollInFlight = false;
91
+ let mutationStateRefreshInFlight = false;
92
+ let mutationStateRefreshTimer = null;
90
93
 
91
94
  start().catch((error) => {
92
95
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -101,7 +104,7 @@ async function start() {
101
104
  window.addEventListener("scroll", positionCurrentOnboarding, true);
102
105
  render();
103
106
  scheduleRepositorySyncPoll();
104
- if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true) {
107
+ if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
105
108
  queueMicrotask(requestOnboarding);
106
109
  }
107
110
  }
@@ -207,6 +210,16 @@ function readinessStageForType(type) {
207
210
  ));
208
211
  }
209
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
+
210
223
  function renderSidebarUtility(utility, route, direct = false) {
211
224
  const directClass = direct ? "nav-direct " : "";
212
225
  if (utility === "obligation-board") return "";
@@ -272,27 +285,28 @@ function renderHome(main) {
272
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>' +
273
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>' +
274
287
  auditPanel + '</div></div>';
275
- 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;
276
294
  }
277
295
 
278
296
  function initialSetupBanner() {
279
- const system = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired")?.record;
297
+ const program = activeProgram();
298
+ const system = initialSetupSystem();
280
299
  if (!system) {
281
- 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>';
282
301
  }
283
- const goal = programGoalFromKind(state.workspace.assuranceGoal);
302
+ const goal = programGoalFromKind(program.assuranceGoal);
284
303
  const goalLabels = {
285
304
  readiness: "Program Readiness",
286
305
  "type-1": "SOC 2 Type 1",
287
306
  "type-2": "SOC 2 Type 2"
288
307
  };
289
- const goalStep = goal === "none"
290
- ? "Choose the program goal."
291
- : "Confirm the saved program goal: " + goalLabels[goal] + ".";
292
- const completion = system.status === "planned"
293
- ? "Confirm the service scope to activate the planned service and continue to Step 1."
294
- : "Confirm the service scope to close onboarding and continue to Step 1.";
295
- 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>';
296
310
  }
297
311
 
298
312
  function readinessOverview() {
@@ -332,6 +346,10 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
332
346
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
333
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>' +
334
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
+ });
335
353
  }
336
354
 
337
355
  function workflowGuidance(options = {}) {
@@ -370,20 +388,54 @@ function collectionReviewPanel(type) {
370
388
  if (!assessment) return "";
371
389
  const configuration = assessment.configuration;
372
390
  const current = assessment.status === "current";
391
+ const needsFirstRecord = collectionNeedsFirstRecord(type);
373
392
  const reviewerNames = (assessment.review?.reviewedByIds || [])
374
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
+ : "";
375
397
  const reviewSummary = current
376
- ? '<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>'
377
- : '<p class="collection-review-result"><strong>' + (assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(assessment.message) + '</span></p>';
378
- 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>' +
379
406
  '<details ' + (current ? "" : "open") + '><summary>What to review</summary><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></details>' +
380
- '<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");
420
+ }
421
+
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.";
381
430
  }
382
431
 
383
- function resourceReviewCriteria(type) {
432
+ function resourceReviewCriteria(type, collapsed = false) {
384
433
  const reviewPoints = state.model.resources[type]?.guidance?.reviewPoints || [];
385
434
  if (!reviewPoints.length) return "";
386
- 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>';
387
439
  }
388
440
 
389
441
  function recordWorkflowItems(type, id) {
@@ -409,7 +461,8 @@ function recordWorkflowCell(type, entry) {
409
461
  if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
410
462
  const item = items[0];
411
463
  const href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
412
- 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>';
413
466
  }
414
467
 
415
468
  function openCollectionReviewDialog(type) {
@@ -417,7 +470,9 @@ function openCollectionReviewDialog(type) {
417
470
  if (!assessment) return;
418
471
  const configuration = assessment.configuration;
419
472
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
420
- 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");
421
476
  const allowedDecisions = configuration.decisions || ["complete"];
422
477
  const defaultDecision = assessment.review?.decision
423
478
  || (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
@@ -427,15 +482,17 @@ function openCollectionReviewDialog(type) {
427
482
  const dialog = document.createElement("dialog");
428
483
  dialog.className = "commit-dialog event-dialog collection-review-dialog";
429
484
  dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
430
- 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>';
431
486
  document.body.append(dialog);
432
487
  dialog.showModal();
433
488
  const form = dialog.querySelector("form");
489
+ const saveStatus = dialog.querySelector(".review-save-status");
490
+ const repositoryPrefetch = prefetchRepositoryForReview(saveStatus);
434
491
  const systemField = dialog.querySelector("[data-authoritative-system]");
435
492
  const syncDecision = () => {
436
493
  const external = form.elements.decision.value === "externally-managed";
437
494
  systemField.hidden = !external;
438
- form.elements.authoritativeSystemId.required = external;
495
+ form.elements.authoritativeSourceId.required = external;
439
496
  };
440
497
  syncDecision();
441
498
  dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
@@ -459,7 +516,7 @@ function openCollectionReviewDialog(type) {
459
516
  rationale: form.elements.rationale.value.trim(),
460
517
  reviewedByIds: [form.elements.reviewerId.value],
461
518
  reviewedOn: form.elements.reviewedOn.value,
462
- authoritativeSystemId: form.elements.authoritativeSystemId.value || undefined,
519
+ [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: form.elements.authoritativeSourceId.value || undefined,
463
520
  expectedRevision: assessment.reviewRevision || undefined
464
521
  };
465
522
  form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
@@ -476,12 +533,15 @@ function openCollectionReviewDialog(type) {
476
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>';
477
534
  dialog.querySelector("[data-preview-collection-review]").textContent = "Confirm and save";
478
535
  } else {
536
+ saveStatus.textContent = "Validating and saving…";
537
+ const prefetch = await repositoryPrefetch;
479
538
  const response = await localFetch("/api/collection-review", {
480
539
  method: "POST",
481
- headers: { "content-type": "application/json" },
482
- 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 })
483
542
  });
484
543
  if (!response.ok) throw new Error(await responseMessage(response));
544
+ saveStatus.textContent = "Saved locally. Refreshing page…";
485
545
  applyMutationState(await response.json());
486
546
  dialog.close();
487
547
  render();
@@ -582,11 +642,18 @@ function renderEvidenceReadiness() {
582
642
  ?.find((stage) => stage.id === "controls")
583
643
  ?.items.filter((item) => item.id.startsWith("source-family-")) || [];
584
644
  const completeCount = items.filter((item) => item.status === "complete").length;
585
- const cards = items.map((item) => {
586
- const sources = (item.sourceSystemIds || []).map((id) => {
587
- const source = state.resources.find(({ record }) => record.type === "system" && record.id === id)?.record;
588
- const sourceCheck = (item.sourceSystemChecks || []).find(({ sourceSystemId }) => sourceSystemId === id);
589
- 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);
590
657
  const status = complete
591
658
  ? "Ready"
592
659
  : Object.entries(sourceCheck?.checks || {})
@@ -594,23 +661,26 @@ function renderEvidenceReadiness() {
594
661
  .map(([name]) => evidenceSourceCheckLabel(name))
595
662
  .join(", ") || "Needs details";
596
663
  return source
597
- ? '<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>'
598
665
  : "";
599
666
  }).join("");
600
667
  const sourceAction = sources
601
668
  ? sources
602
- : '<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>';
603
670
  const method = item.operationRecordTypes?.length
604
671
  ? "FileGRC records: " + item.operationRecordTypes.map(properCase).join(", ")
605
672
  : properCase(item.evidenceForm || "External evidence");
606
- 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>' +
607
- (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>' : "") +
608
675
  (item.evidencePrompt ? '<div class="evidence-map-expectation"><strong>Expected evidence</strong><span>' + esc(item.evidencePrompt) + '</span></div>' : "") +
609
676
  (item.timing ? '<div class="evidence-map-expectation"><strong>When</strong><span>' + esc(item.timing) + '</span></div>' : "") +
610
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>';
611
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
+ : "";
612
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>';
613
- 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>';
614
684
  }
615
685
 
616
686
  function evidenceSourceCheckLabel(name) {
@@ -663,6 +733,7 @@ function stagePageItemDetail(item) {
663
733
  ? String(item.message || "").match(/^Complete (\d+) checks before implementation:/)
664
734
  : null;
665
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.";
666
737
  return item.message || workflowItemDetail(item);
667
738
  }
668
739
 
@@ -756,7 +827,7 @@ function stagePageItems(stage, destination) {
756
827
 
757
828
  function operationProgress() {
758
829
  const program = state.programReadiness;
759
- const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
830
+ const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
760
831
  const asOf = program?.asOf || currentDate();
761
832
  const candidateStarted = goal === "soc-2-type-2"
762
833
  ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
@@ -837,7 +908,7 @@ function sectionDestinations(section) {
837
908
  destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
838
909
  }
839
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." });
840
- 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." });
841
912
  return destinations;
842
913
  }
843
914
 
@@ -857,10 +928,10 @@ function renderExternalEvidenceSection() {
857
928
  )).join("");
858
929
  const createButton = state.readOnly
859
930
  ? ""
860
- : '<button class="button primary" type="button" data-new-external-evidence>New external evidence</button>';
861
- 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">' +
862
933
  createButton + '<a class="button" href="#/resources/evidence">View all</a></div></div><div class="external-evidence-list">' +
863
- (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.")) +
864
935
  '</div></section>';
865
936
  }
866
937
 
@@ -1009,6 +1080,12 @@ function coverageEnd(coverage) {
1009
1080
  }
1010
1081
 
1011
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
+ }
1012
1089
  const definitions = state.workspace.classificationDefinitions || {};
1013
1090
  return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
1014
1091
  }
@@ -1071,7 +1148,7 @@ function obligationCompletionPlan(item) {
1071
1148
  if (!currentPeopleForParties(item.ownerIds || []).length) {
1072
1149
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
1073
1150
  }
1074
- 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"))) {
1075
1152
  return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
1076
1153
  }
1077
1154
  if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
@@ -1123,7 +1200,7 @@ function obligationCompletionSeed(type, item, obligation) {
1123
1200
  .filter((record) => record.status === "active" && !responsiblePeople.includes(record.id))
1124
1201
  .map(({ id }) => id);
1125
1202
  const reviewerPeople = independentPeople.length ? [independentPeople[0]] : [];
1126
- 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);
1127
1204
  const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
1128
1205
  const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
1129
1206
  const common = { title };
@@ -1386,8 +1463,11 @@ function openApplicabilityReviewDialog(type, entries) {
1386
1463
  const definition = state.model.resources[type];
1387
1464
  const reviewPoints = definition?.guidance?.reviewPoints || [];
1388
1465
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
1466
+ const reviewedRequirements = reviewedRequirementIds();
1389
1467
  const pending = entries.filter(({ record }) => (
1390
- !record.applicabilityReview
1468
+ type === "requirement" && String(state.model.modelVersion) === "4"
1469
+ ? !reviewedRequirements.has(record.id)
1470
+ : !record.applicabilityReview
1391
1471
  || type === "requirement" && record.applicability === "undetermined"
1392
1472
  ));
1393
1473
  const dialog = document.createElement("dialog");
@@ -1400,10 +1480,12 @@ function openApplicabilityReviewDialog(type, entries) {
1400
1480
  const reviewChecks = reviewPoints.length
1401
1481
  ? '<section class="collection-review-checks"><strong>Before deciding</strong><ul>' + reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>'
1402
1482
  : "";
1403
- 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>';
1404
1484
  document.body.append(dialog);
1405
1485
  dialog.showModal();
1406
1486
  const form = dialog.querySelector("form");
1487
+ const saveStatus = dialog.querySelector(".review-save-status");
1488
+ const repositoryPrefetch = prefetchRepositoryForReview(saveStatus);
1407
1489
  dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
1408
1490
  dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
1409
1491
  dialog.addEventListener("close", () => dialog.remove());
@@ -1438,11 +1520,17 @@ function openApplicabilityReviewDialog(type, entries) {
1438
1520
  error.textContent = "Select at least one decision.";
1439
1521
  return;
1440
1522
  }
1523
+ const decisionIds = new Set(decisions.map(({ id }) => id));
1441
1524
  const payload = {
1442
1525
  decisions,
1443
1526
  reviewedByIds: [form.elements.reviewerId.value],
1444
1527
  reviewedOn: form.elements.reviewedOn.value,
1445
- 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
+ ])
1446
1534
  };
1447
1535
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
1448
1536
  try {
@@ -1455,15 +1543,18 @@ function openApplicabilityReviewDialog(type, entries) {
1455
1543
  if (!response.ok) throw new Error(await responseMessage(response));
1456
1544
  const preview = await response.json();
1457
1545
  previewedPayload = payload;
1458
- 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>';
1459
1547
  dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
1460
1548
  } else {
1549
+ saveStatus.textContent = "Validating and saving…";
1550
+ const prefetch = await repositoryPrefetch;
1461
1551
  const response = await localFetch("/api/applicability-review", {
1462
1552
  method: "POST",
1463
- headers: { "content-type": "application/json" },
1464
- 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 })
1465
1555
  });
1466
1556
  if (!response.ok) throw new Error(await responseMessage(response));
1557
+ saveStatus.textContent = "Saved locally. Refreshing page…";
1467
1558
  applyMutationState(await response.json());
1468
1559
  dialog.close();
1469
1560
  render();
@@ -1498,10 +1589,10 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
1498
1589
  ["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
1499
1590
  ["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
1500
1591
  ["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
1501
- ["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"],
1502
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"]
1503
1594
  ];
1504
- 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>';
1505
1596
  const dateFields = typeOne
1506
1597
  ? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
1507
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>';
@@ -1602,10 +1693,10 @@ function renderPacketResults(container, result) {
1602
1693
  container.innerHTML = '<section class="metrics packet-metrics">' +
1603
1694
  metric("filegrc Evidence", packet.summary.filegrcRecords, packet.summary.records + " total packet records", "neutral") +
1604
1695
  metric("Obligations", packet.summary.obligationOccurrences, packet.summary.eventRuns + " event workflows", "neutral") +
1605
- 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") +
1606
1697
  metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
1607
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>' +
1608
- '<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>';
1609
1700
  }
1610
1701
 
1611
1702
  function obligationPreview(items) {
@@ -1699,10 +1790,12 @@ function renderList(main, type, params = new URLSearchParams()) {
1699
1790
  const values = [...new Set(observed)].sort();
1700
1791
  return { name, label: field.label || humanize(name), values };
1701
1792
  }).filter(({ values }) => values.length > 1);
1702
- 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();
1703
1795
  const hasPendingApplicability = entries.some(({ record }) => (
1704
- !record.applicabilityReview
1705
- || 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"
1706
1799
  ));
1707
1800
  const applicabilityButton = !state.readOnly
1708
1801
  && ["requirement", "control", "commitment", "complementary-control"].includes(type)
@@ -1730,7 +1823,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1730
1823
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
1731
1824
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
1732
1825
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
1733
- 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>';
1734
1827
  pagination.hidden = totalPages === 1;
1735
1828
  previous.disabled = pageNumber === 1;
1736
1829
  next.disabled = pageNumber === totalPages;
@@ -1772,7 +1865,7 @@ function renderList(main, type, params = new URLSearchParams()) {
1772
1865
  main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
1773
1866
  main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
1774
1867
  main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
1775
- 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));
1776
1869
  if (params.get("review") === "1" && !state.readOnly) {
1777
1870
  queueMicrotask(() => main.querySelector("#review-applicability")?.click());
1778
1871
  }
@@ -1878,10 +1971,16 @@ function renderDetail(main, type, id) {
1878
1971
  main.querySelector("[data-evidence-file]")?.click();
1879
1972
  });
1880
1973
  main.querySelector("[data-evidence-file]")?.addEventListener("change", async (event) => {
1881
- const file = event.currentTarget.files?.[0];
1974
+ const input = event.currentTarget;
1975
+ const file = input.files?.[0];
1882
1976
  if (!file) return;
1883
- if (!confirm('Attach "' + file.name + '" to this Evidence record? Confirm that its classification, retention, and repository access are appropriate.')) {
1884
- 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 = "";
1885
1984
  return;
1886
1985
  }
1887
1986
  try {
@@ -1894,12 +1993,19 @@ function renderDetail(main, type, id) {
1894
1993
  applyMutationState(await response.json());
1895
1994
  render();
1896
1995
  } catch (error) {
1996
+ input.value = "";
1897
1997
  showError(error.message);
1898
1998
  }
1899
1999
  });
1900
2000
  main.querySelectorAll("[data-detach-evidence]").forEach((button) => button.addEventListener("click", async () => {
1901
2001
  const attachment = button.dataset.detachEvidence;
1902
- 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;
1903
2009
  try {
1904
2010
  const response = await localFetch(
1905
2011
  "/api/evidence-attachments/" + encodeURIComponent(entry.record.id) + "/" + encodeURIComponent(attachment)
@@ -1916,7 +2022,13 @@ function renderDetail(main, type, id) {
1916
2022
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
1917
2023
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
1918
2024
  main.querySelector("#delete-resource")?.addEventListener("click", async () => {
1919
- 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;
1920
2032
  try {
1921
2033
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
1922
2034
  if (!response.ok) return showError(await responseMessage(response));
@@ -2182,13 +2294,10 @@ function renderTrunkRepository(main) {
2182
2294
  const lastSync = repository.lastSuccessfulSynchronization
2183
2295
  ? formatLocalDateTime(repository.lastSuccessfulSynchronization)
2184
2296
  : "No successful sync recorded by this server";
2185
- const override = repository.developmentOverride
2186
- ? '<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>'
2187
- : "";
2188
2297
  const validationBody = state.validation.diagnostics.length
2189
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>'
2190
2299
  : empty("No validation problems.");
2191
- 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>';
2192
2301
  main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
2193
2302
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
2194
2303
  }
@@ -2376,13 +2485,14 @@ function rendererSettingsEntry() {
2376
2485
  return state.resources.find(({ record }) => record.type === "renderer-settings");
2377
2486
  }
2378
2487
 
2379
- function requestOnboarding() {
2488
+ function requestOnboarding({ setupOnly = false } = {}) {
2380
2489
  if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
2381
2490
  if (parseRoute().name !== "home") {
2382
2491
  history.replaceState(null, "", "#/");
2383
2492
  render();
2384
2493
  }
2385
- onboardingStep = 0;
2494
+ onboardingStep = setupOnly ? onboardingSteps().length - 1 : 0;
2495
+ onboardingSetupOnly = setupOnly;
2386
2496
  onboardingDraft = initialOnboardingDraft();
2387
2497
  onboardingBusy = false;
2388
2498
  onboardingShade = document.createElement("div");
@@ -2407,23 +2517,25 @@ function requestOnboarding() {
2407
2517
  onboardingDialog = null;
2408
2518
  onboardingDraft = null;
2409
2519
  onboardingBusy = false;
2520
+ onboardingSetupOnly = false;
2410
2521
  onboardingPendingDraft = false;
2411
2522
  });
2412
2523
  renderOnboardingStep();
2413
2524
  }
2414
2525
 
2415
2526
  function initialOnboardingDraft() {
2416
- const systemEntry = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired");
2527
+ const program = activeProgram();
2528
+ const system = initialSetupSystem();
2417
2529
  const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
2418
2530
  return {
2419
- systemId: systemEntry?.record.id || "",
2420
- serviceName: systemEntry?.record.title || "",
2421
- scope: systemEntry?.record.description || "",
2422
- ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
2423
- criticality: systemEntry?.record.criticality || "high",
2424
- classificationId: systemEntry?.record.classificationId || defaultClassificationId(),
2425
- internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
2426
- 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)
2427
2539
  };
2428
2540
  }
2429
2541
 
@@ -2432,60 +2544,51 @@ function onboardingSteps() {
2432
2544
  target: ".repo-chip",
2433
2545
  kicker: "Mental model",
2434
2546
  title: "Files are the program",
2435
- 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.",
2436
2548
  points: [
2437
- "Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
2438
- "JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
2439
- "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."
2440
2552
  ]
2441
2553
  };
2442
2554
  const path = {
2443
2555
  target: ".readiness-map",
2444
2556
  kicker: "Program model",
2445
2557
  title: "Follow the audit chain",
2446
- body: "Follow the five program steps in order. Each step reveals the records and decisions needed next.",
2447
- points: [
2448
- "Define the people, criteria, service, Systems, and providers in scope.",
2449
- "Approve the policies, implement the controls, then operate them and retain dated proof.",
2450
- "Create the audit engagement when a CPA firm is involved or a real customer deadline requires it."
2451
- ]
2558
+ body: "Follow five steps. Each page shows the next decision.",
2452
2559
  };
2453
2560
  const operation = {
2454
2561
  target: ".obligation-panel",
2455
2562
  kicker: "Program operation",
2456
- title: "Work the queue and trigger policy events",
2457
- 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.",
2458
2565
  points: [
2459
- "Complete each occurrence with the requested dated operating record and supporting evidence.",
2460
- "Start Policy Events only after the real event occurs. FileGRC creates one owned Action Item for each applicable policy step.",
2461
- "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."
2462
2568
  ]
2463
2569
  };
2464
2570
  const auditPath = {
2465
2571
  target: null,
2466
2572
  kicker: "Report goal and audit",
2467
- title: "Choose the report goal and plan fieldwork",
2468
- body: [
2469
- "SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
2470
- "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."
2471
- ],
2573
+ title: "Choose a goal",
2574
+ body: "SOC 2 is an independent CPA report on controls tied to selected Trust Services Criteria.",
2472
2575
  sections: [
2473
2576
  {
2474
2577
  title: "Type 1",
2475
- 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."
2476
2579
  },
2477
2580
  {
2478
2581
  title: "Type 2",
2479
- 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."
2480
2583
  }
2481
2584
  ],
2482
- 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."
2483
2586
  };
2484
2587
  const setup = {
2485
2588
  target: null,
2486
2589
  kicker: "Initial scope",
2487
- title: "Describe the service you plan to audit",
2488
- 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."
2489
2592
  };
2490
2593
  return [
2491
2594
  files,
@@ -2512,10 +2615,10 @@ function renderOnboardingStep() {
2512
2615
  ? onboardingSetupForm()
2513
2616
  : description + explanation + afterSections;
2514
2617
  const finalActions = onboardingStep === steps.length - 1
2515
- ? '<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>'
2516
2619
  : '<button class="button primary" type="button" data-onboarding="next">Next</button>';
2517
- 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>';
2518
- 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);
2519
2622
  onboardingDialog.querySelector('[data-onboarding="back"]')?.addEventListener("click", () => {
2520
2623
  captureOnboardingForm();
2521
2624
  onboardingStep -= 1;
@@ -2548,16 +2651,21 @@ function renderOnboardingStep() {
2548
2651
 
2549
2652
  function onboardingSetupForm() {
2550
2653
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
2551
- 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 || {});
2552
2660
  if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
2553
2661
  classifications.push(onboardingDraft.classificationId);
2554
2662
  }
2555
2663
  const gitStatus = state.repository?.mode === "trunk"
2556
- ? '<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>'
2557
2665
  : state.git.available && state.git.branch
2558
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>'
2559
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>';
2560
- 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>';
2561
2669
  }
2562
2670
 
2563
2671
  function captureOnboardingForm() {
@@ -2837,8 +2945,9 @@ function openEditor(type, entry = null, options = {}) {
2837
2945
  const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
2838
2946
  const editorDescription = options.description
2839
2947
  || implementationEditorDescription(type)
2840
- || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
2841
- 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>' +
2842
2951
  activeMarkdown.map((markdown) => {
2843
2952
  const generated = !entry?.content?.[markdown.name];
2844
2953
  const source = entry?.content?.[markdown.name]?.source
@@ -2857,6 +2966,8 @@ function openEditor(type, entry = null, options = {}) {
2857
2966
  dialog.addEventListener("close", () => dialog.remove());
2858
2967
  dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
2859
2968
  wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
2969
+ wireStructuredObjectEditors(dialog);
2970
+ wireChoicePickers(dialog);
2860
2971
  dialog.querySelector(".advanced-editor textarea").addEventListener("input", () => {
2861
2972
  dialog.dataset.jsonDirty = "true";
2862
2973
  dialog.querySelector("form").noValidate = true;
@@ -2942,22 +3053,79 @@ function openEditor(type, entry = null, options = {}) {
2942
3053
  }
2943
3054
 
2944
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
+ }
2945
3065
  if (type === "system") {
2946
- 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.";
2947
3070
  }
2948
3071
  if (type === "control") {
2949
- 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.";
2950
3112
  }
2951
3113
  return "";
2952
3114
  }
2953
3115
 
3116
+ function conciseResourceDescription(definition) {
3117
+ const description = definition?.description?.trim() || "";
3118
+ return description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() || description;
3119
+ }
3120
+
2954
3121
  function seedRecord(type, definition) {
2955
3122
  const record = { id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
2956
3123
  const fields = { ...state.model.commonFields, ...definition.fields };
2957
3124
  for (const name of definition.required || []) {
2958
3125
  const field = fields[name];
2959
3126
  if (record[name] !== undefined) continue;
2960
- if (field.relation) {
3127
+ if (field.default !== undefined) record[name] = structuredClone(field.default);
3128
+ else if (field.relation) {
2961
3129
  const candidates = relationCandidates(field);
2962
3130
  record[name] = field.type === "array" ? (candidates.length === 1 ? [candidates[0].record.id] : []) : (candidates.length === 1 ? candidates[0].record.id : "");
2963
3131
  }
@@ -3011,12 +3179,18 @@ function renderRecordContentEditor(type, entry, options) {
3011
3179
  if (!config) return "";
3012
3180
  const item = entry?.content?.[config.slot];
3013
3181
  const source = item?.source || "";
3014
- 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>';
3015
3183
  if (config.mode === "default") return editor;
3016
3184
  const open = item || options.addRecordContent;
3017
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>';
3018
3186
  }
3019
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
+
3020
3194
  function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired) {
3021
3195
  const label = fieldLabel(type, name);
3022
3196
  const requiredMark = required || field.requiredWhen || oneOfRequired
@@ -3034,21 +3208,22 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3034
3208
  if (field.relation && field.type === "array") {
3035
3209
  const candidates = relationCandidates(field);
3036
3210
  control = candidates.length
3037
- ? '<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>'
3038
- : 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>';
3039
3213
  return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
3040
3214
  }
3041
3215
  if (field.relation) {
3042
3216
  const candidates = relationCandidates(field);
3043
3217
  control = candidates.length
3044
- ? '<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>'
3045
- : 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>';
3046
3220
  return fieldWrap(name, "relation", label, requiredMark, control, help, required);
3047
3221
  }
3048
3222
  if (name === "classificationId") {
3049
- 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 || {});
3050
3225
  control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
3051
- 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);
3052
3227
  }
3053
3228
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
3054
3229
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
@@ -3060,15 +3235,30 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
3060
3235
  return fieldWrap(name, "boolean", label, requiredMark, control, help, required);
3061
3236
  }
3062
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
+ }
3063
3243
  control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
3064
3244
  return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
3065
3245
  }
3066
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
+ }
3067
3260
  control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
3068
- const arrayHelp = name === "evidenceSourceKinds"
3069
- ? "One role per line. Use the roles required by the Controls this System supports, such as " + evidenceSourceRoleOptions().join(", ") + "."
3070
- : "One value per line";
3071
- return fieldWrap(name, "array", label, requiredMark, control, arrayHelp, required);
3261
+ return fieldWrap(name, "array", label, requiredMark, control, "One value per line", required);
3072
3262
  }
3073
3263
  if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
3074
3264
  control = '<textarea>' + esc(value ?? "") + '</textarea>';
@@ -3086,6 +3276,178 @@ function evidenceSourceRoleOptions() {
3086
3276
  return [...new Set((state.model.evidenceSourceFamilies || []).flatMap(({ sourceKinds }) => sourceKinds || []))].sort();
3087
3277
  }
3088
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
+
3089
3451
  function fieldWrap(name, kind, label, requiredMark, control, help, required) {
3090
3452
  const labelId = "field-label-" + name;
3091
3453
  let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
@@ -3211,18 +3573,28 @@ function readGuidedRecord(dialog, base, fields) {
3211
3573
  }
3212
3574
  const kind = group.dataset.kind;
3213
3575
  let value;
3214
- 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);
3215
3577
  else {
3216
3578
  const control = group.querySelector("input,select,textarea");
3217
3579
  const raw = control?.value ?? "";
3218
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"));
3219
3587
  else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
3220
3588
  else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
3221
3589
  else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
3222
3590
  else if (kind === "number") value = raw === "" ? undefined : Number(raw);
3223
3591
  else value = raw;
3224
3592
  }
3225
- 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];
3226
3598
  else record[name] = value;
3227
3599
  }
3228
3600
  return record;
@@ -3232,8 +3604,20 @@ function relationCandidates(field) {
3232
3604
  return state.resources.filter(({ record }) => field.relation.includes("*") || field.relation.includes(record.type));
3233
3605
  }
3234
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
+
3235
3618
  function relationHelp(field) {
3236
3619
  if (field.relation.includes("*")) return "References any resource";
3620
+ if (field.relation.length > 3) return "References supported records";
3237
3621
  const labels = field.relation.map((type) => state.model.resources[type]?.pluralTitle || type);
3238
3622
  if (labels.length < 2) return "References " + labels.join("");
3239
3623
  if (labels.length === 2) return "References " + labels.join(" or ");
@@ -3333,7 +3717,14 @@ function globalSearch(query) {
3333
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>';
3334
3718
  document.body.append(dialog);
3335
3719
  dialog.showModal();
3336
- 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
+ };
3337
3728
  const results = dialog.querySelector(".result-list");
3338
3729
  const pagination = dialog.querySelector(".search-pagination");
3339
3730
  const previous = pagination.querySelector('[data-search-page="previous"]');
@@ -3344,7 +3735,10 @@ function globalSearch(query) {
3344
3735
  const start = (pageNumber - 1) * SEARCH_PAGE_SIZE;
3345
3736
  const visible = matches.slice(start, start + SEARCH_PAGE_SIZE);
3346
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.");
3347
- results.querySelectorAll("a").forEach((link) => link.onclick = () => dialog.close());
3738
+ results.querySelectorAll("a").forEach((link) => link.onclick = () => {
3739
+ clearSearch();
3740
+ dialog.close();
3741
+ });
3348
3742
  pagination.hidden = totalPages === 1;
3349
3743
  previous.disabled = pageNumber === 1;
3350
3744
  next.disabled = pageNumber === totalPages;
@@ -3363,7 +3757,10 @@ function globalSearch(query) {
3363
3757
  results.scrollTop = 0;
3364
3758
  });
3365
3759
  renderResults();
3366
- dialog.addEventListener("close", () => dialog.remove());
3760
+ dialog.addEventListener("close", () => {
3761
+ clearSearch();
3762
+ dialog.remove();
3763
+ });
3367
3764
  }
3368
3765
 
3369
3766
  function auditProgress(audit) {
@@ -3538,9 +3935,15 @@ function conditionMatchesValues(condition, valueFor) {
3538
3935
  function conditionValueMatches(actual, expected) {
3539
3936
  return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
3540
3937
  }
3541
- function formatValue(value, field, type) {
3938
+ function formatValue(value, field, type, compact = false) {
3542
3939
  if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
3543
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
+ }
3544
3947
  if (definition?.type === "date") return esc(formatCalendarDate(value));
3545
3948
  if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
3546
3949
  if (type === "obligation" && field === "status") {
@@ -3548,7 +3951,12 @@ function formatValue(value, field, type) {
3548
3951
  return '<span class="badge ' + (value === "active" ? "neutral" : "status-" + esc(String(value))) + '">' + esc(label) + '</span>';
3549
3952
  }
3550
3953
  if (field === "status" || field.endsWith("Rating") || field === "severity" || field === "outcome") return '<span class="badge status-' + esc(String(value)) + '">' + esc(properCase(value)) + '</span>';
3551
- 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
+ }
3552
3960
  if (field === "sourceReference" && typeof value === "object") {
3553
3961
  const href = safeExternalUrl(value.url);
3554
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>';
@@ -3560,6 +3968,33 @@ function formatValue(value, field, type) {
3560
3968
  if (definition?.type === "enum") return esc(properCase(value));
3561
3969
  return esc(String(value));
3562
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
+ }
3563
3998
  function controlOperationTracking(control) {
3564
3999
  const obligations = resourcesOfType("obligation")
3565
4000
  .map(({ record }) => record)
@@ -3643,13 +4078,97 @@ function pluralize(noun, count) {
3643
4078
  if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + "ies";
3644
4079
  return noun + "s";
3645
4080
  }
3646
- 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
+ }
3647
4092
  function applyMutationState(result) {
3648
- if (!result?.state) throw new Error("The save response did not include the current workspace state.");
3649
- 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
+ }
3650
4101
  scheduleRepositorySyncPoll(result.synchronization);
3651
4102
  }
3652
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
+
3653
4172
  function scheduleRepositorySyncPoll(synchronization = state.repository?.backgroundSynchronization) {
3654
4173
  const syncing = synchronization?.status === "syncing"
3655
4174
  || state.repository?.status === "syncing";
@@ -3724,6 +4243,30 @@ function showError(message) {
3724
4243
  dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
3725
4244
  dialog.addEventListener("close", () => dialog.remove());
3726
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
+
3727
4270
  async function responseMessage(response) {
3728
4271
  const source = await response.text();
3729
4272
  try { return JSON.parse(source).error || source; } catch { return source; }
@@ -3732,7 +4275,7 @@ async function localFetch(url, options) {
3732
4275
  const method = String(options?.method || "GET").toUpperCase();
3733
4276
  const synchronizing = state?.repository?.mode === "trunk"
3734
4277
  && ["POST", "PUT", "DELETE"].includes(method)
3735
- && url !== "/api/evidence-packet";
4278
+ && !["/api/evidence-packet", "/api/git/prefetch"].includes(url);
3736
4279
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
3737
4280
  const previousChip = chip?.innerHTML;
3738
4281
  let repositoryRefreshed = false;
@@ -3777,7 +4320,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
3777
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}
3778
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)}
3779
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}
3780
- .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}
3781
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}
3782
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)}
3783
4326
  .button{text-decoration:none}
@@ -3787,7 +4330,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
3787
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}
3788
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}
3789
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}
3790
- .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}
3791
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}
3792
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}
3793
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}
@@ -3797,16 +4340,18 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
3797
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)}
3798
4341
  .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
3799
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}
3800
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}}
3801
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))}}
3802
- @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}}
3803
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}}
3804
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}}
3805
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}}
3806
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}}
3807
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}}
3808
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}}
3809
- @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)}}
3810
4355
 
3811
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}
3812
4357
  body,button,input,select,textarea,dialog{color:var(--ink)}
@@ -3881,6 +4426,8 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
3881
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}
3882
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}
3883
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}
3884
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}
3885
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}
3886
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}
@@ -3890,7 +4437,11 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
3890
4437
  @media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
3891
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}}
3892
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}}
3893
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}}
3894
4445
 
3895
4446
  @media(prefers-color-scheme:dark){
3896
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)}