filegrc 0.3.4 → 0.4.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
@@ -8,7 +8,7 @@ import {
8
8
  utcCalendarDate,
9
9
  validCalendarRecurrence
10
10
  } from "./recurrence.js";
11
- import { POLICY_EVENT_NAMES, PROGRAM_PATH, RESOURCE_INSTRUCTIONS } from "./program-path.js";
11
+ import { PROGRAM_PATH, RESOURCE_INSTRUCTIONS } from "./program-path.js";
12
12
  import { formatCalendarDate, formatLocalDateTime } from "./time.js";
13
13
 
14
14
  export function renderIndex(state = null) {
@@ -54,29 +54,7 @@ const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
54
54
  ...RESOURCE_INSTRUCTIONS,
55
55
  "utility:audit-packet": "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet."
56
56
  })};
57
- const POLICY_EVENT_NAMES = ${JSON.stringify(POLICY_EVENT_NAMES)};
58
- const STAGE_PAGE_ID_ALIASES = {
59
- "controls:complementary-control": ["scope:complementary-control"]
60
- };
61
- const OBLIGATION_COMPLETION_TYPES = {
62
- "access-review": "access-review",
63
- "backup-test": "backup-test",
64
- "continuity-review": "evidence",
65
- exercise: "exercise",
66
- "inventory-review": "evidence",
67
- "log-review": "evidence",
68
- meeting: "meeting",
69
- "network-review": "evidence",
70
- "penetration-test": "penetration-test",
71
- "performance-review": "evidence",
72
- "policy-review": "policy-review",
73
- "risk-assessment": "risk-assessment",
74
- "security-scan": "evidence",
75
- training: "attestation",
76
- "vendor-review": "vendor-review",
77
- "vulnerability-scan": "vulnerability-scan"
78
- };
79
- const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "acceptanceRationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
57
+ const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
80
58
  const FINDING_SOURCE_TYPES = new Set(["control-test", "policy-review", "meeting", "risk", "risk-assessment", "vendor-review", "access-review", "incident", "exercise", "backup-test", "penetration-test", "audit"]);
81
59
  const ACTION_ITEM_SOURCE_TYPES = new Set(["finding", "exception", "policy-review", "meeting", "risk", "risk-assessment", "vendor-review", "access-review", "vulnerability", "incident", "exercise", "backup-test", "data-request", "audit-request"]);
82
60
  const TITLE_CASE_MINOR_WORDS = new Set(["a", "an", "and", "as", "at", "but", "by", "for", "in", "nor", "of", "on", "or", "per", "the", "to", "via", "vs"]);
@@ -86,7 +64,12 @@ let onboardingShade = null;
86
64
  let onboardingStep = 0;
87
65
  let onboardingDraft = null;
88
66
  let onboardingBusy = false;
67
+ let onboardingStillWorkingTimer = null;
68
+ let onboardingPendingDraft = false;
69
+ const resourceDetailRequests = new Map();
89
70
  let resourceGuideCleanup = null;
71
+ let repositorySyncPollTimer = null;
72
+ let repositorySyncPollInFlight = false;
90
73
 
91
74
  start().catch((error) => {
92
75
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
@@ -100,6 +83,7 @@ async function start() {
100
83
  window.addEventListener("resize", positionCurrentOnboarding);
101
84
  window.addEventListener("scroll", positionCurrentOnboarding, true);
102
85
  render();
86
+ scheduleRepositorySyncPoll();
103
87
  if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true) {
104
88
  queueMicrotask(requestOnboarding);
105
89
  }
@@ -112,7 +96,7 @@ function render() {
112
96
  if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
113
97
  const route = parseRoute();
114
98
  const nav = buildNavigation(route);
115
- root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header><main id="main"></main></div></div>';
99
+ root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header>' + repositorySyncAlert() + '<main id="main"></main></div></div>';
116
100
  const nextNavigation = root.querySelector(".sidebar-nav");
117
101
  if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
118
102
  const main = root.querySelector("main");
@@ -121,7 +105,7 @@ function render() {
121
105
  else if (route.name === "obligations") renderObligations(main, route.params);
122
106
  else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
123
107
  else if (route.name === "list") renderList(main, route.type, route.params);
124
- else if (route.name === "detail") renderDetail(main, route.type, route.id);
108
+ else if (route.name === "detail") renderDetail(main, route.type, route.id, route.params);
125
109
  else if (route.name === "organization") renderOrganization(main);
126
110
  else if (route.name === "repository") renderRepository(main);
127
111
  else renderNotFound(main);
@@ -141,7 +125,7 @@ function parseRoute() {
141
125
  if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
142
126
  if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
143
127
  if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
144
- if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2] };
128
+ if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2], params: new URLSearchParams(query) };
145
129
  if (parts.length === 1 && parts[0] === "organization") return { name: "organization" };
146
130
  if (parts.length === 1 && parts[0] === "repository") return { name: "repository" };
147
131
  return { name: "missing" };
@@ -149,13 +133,15 @@ function parseRoute() {
149
133
 
150
134
  function buildNavigation(route) {
151
135
  const currentStage = readinessStageForRoute(route);
136
+ const contextualStageId = route.params?.get("stage");
152
137
  const stages = READINESS_STAGES.map((stage) => {
153
138
  const stagePageCurrent = (route.name === "stage" && route.stageId === stage.id)
154
139
  || (stage.id === "run" && route.name === "obligations");
155
140
  const stageOpen = navigationGroupState[stage.id] ?? currentStage?.id === stage.id;
156
141
  const sections = stage.sections.map((section) => {
157
142
  const sectionKey = stage.id + ":" + section.id;
158
- const sectionCurrent = (route.type && section.types.includes(route.type))
143
+ const sectionCurrent = (route.type && section.types.includes(route.type) && (!contextualStageId || contextualStageId === stage.id))
144
+ || (section.relatedLinks || []).some((link) => route.type === link.type && contextualStageId === stage.id)
159
145
  || (section.utility === "obligation-board" && route.name === "obligations")
160
146
  || (section.utility === "audit-packet" && route.name === "audit-packet");
161
147
  const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
@@ -164,7 +150,11 @@ function buildNavigation(route) {
164
150
  .filter(([, definition]) => definition);
165
151
  const direct = stage.sections.length === 1 || sectionDestinations(section).length === 1;
166
152
  const links = resources.map(([type, definition]) => {
167
- return '<a class="' + (direct ? "nav-direct " : "") + (route.type === type ? "current" : "") + '" href="#/resources/' + encodeURIComponent(type) + '"><span>' + esc(titleCase(definition.pluralTitle)) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
153
+ const current = route.type === type && (!contextualStageId || contextualStageId === stage.id);
154
+ return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="#/resources/' + encodeURIComponent(type) + '"><span>' + esc(titleCase(definition.pluralTitle)) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
155
+ }).join("") + (section.relatedLinks || []).map((link) => {
156
+ const current = route.type === link.type && contextualStageId === stage.id;
157
+ return '<a class="' + (direct ? "nav-direct " : "") + (current ? "current" : "") + '" href="' + esc(link.href) + '"><span>' + esc(link.label) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
168
158
  }).join("") + renderSidebarUtility(section.utility, route, direct);
169
159
  if (direct) return links;
170
160
  return '<section class="nav-group nav-subgroup ' + (sectionOpen ? "open" : "") + '" data-group="' + esc(sectionKey) + '"><button class="nav-subheading-row nav-subgroup-toggle" type="button" aria-label="' + (sectionOpen ? "Collapse " : "Expand ") + esc(section.title) + '" aria-expanded="' + sectionOpen + '" aria-controls="nav-group-' + esc(sectionKey) + '"><span class="nav-subheading">' + esc(section.title) + '</span><svg class="nav-chevron" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2.5 7.5 6 4 9.5"></path></svg></button><div class="nav-items" id="nav-group-' + esc(sectionKey) + '">' + links + '</div></section>';
@@ -179,6 +169,12 @@ function buildNavigation(route) {
179
169
 
180
170
  function readinessStageForRoute(route) {
181
171
  if (route.name === "stage") return READINESS_STAGES.find((stage) => stage.id === route.stageId);
172
+ const contextualStageId = route.params?.get("stage");
173
+ const contextualStage = contextualStageId && READINESS_STAGES.find((stage) => (
174
+ stage.id === contextualStageId
175
+ && stage.sections.some((section) => (section.relatedLinks || []).some((link) => link.type === route.type))
176
+ ));
177
+ if (contextualStage) return contextualStage;
182
178
  return READINESS_STAGES.find((stage) => (
183
179
  (stage.supportingResourceTypes || []).includes(route.type)
184
180
  || stage.sections.some((section) => section.types.includes(route.type)
@@ -232,10 +228,19 @@ function repositoryStatusTone(status) {
232
228
  return "warn";
233
229
  }
234
230
 
231
+ function repositorySyncAlert() {
232
+ if (state.repository?.status === "syncing") {
233
+ return '<div class="repository-sync-alert syncing" role="status" aria-live="polite"><span class="status-dot neutral"></span><span><strong>Saved and committed locally.</strong> Git push is continuing in the background. Other changes unlock when synchronization finishes.</span><a href="#/repository">View status</a></div>';
234
+ }
235
+ const message = state.repository?.backgroundSyncError;
236
+ if (!message) return "";
237
+ return '<div class="repository-sync-alert" role="alert"><span class="status-dot warn"></span><span><strong>Saved locally, but Git sync failed.</strong> ' + esc(message) + '</span><a href="#/repository">Review and retry</a></div>';
238
+ }
239
+
235
240
  function renderHome(main) {
236
241
  const activeAudit = resourcesOfType("audit").find((item) => !["complete", "closed", "cancelled"].includes(item.record.status));
237
242
  const program = state.programReadiness;
238
- const activeFirm = activeAudit && (activeAudit.record.auditorVendorId || (activeAudit.record.auditor && Object.keys(activeAudit.record.auditor).length));
243
+ const activeFirm = activeAudit?.record.auditorVendorId;
239
244
  const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
240
245
  const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
241
246
  const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
@@ -254,7 +259,7 @@ function renderHome(main) {
254
259
  }
255
260
 
256
261
  function initialSetupBanner() {
257
- const system = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired")?.record;
262
+ const system = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired")?.record;
258
263
  if (!system) {
259
264
  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>';
260
265
  }
@@ -286,8 +291,7 @@ function readinessOverview() {
286
291
  const stages = [
287
292
  programStage("scope", "Confirm program ownership, criteria, and commitments, then describe the service, supporting systems, and dependencies.", "#/stage/scope"),
288
293
  programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
289
- programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
290
- programStage("evidence", "For each control family, finish the source-system instructions and verify a real test export or capture.", "#/stage/evidence"),
294
+ programStage("controls", "Finish the internal control set and every authoritative evidence source, then record any complementary controls.", "#/stage/controls"),
291
295
  programStage("run", "Begin the candidate period, maintain risk assessments, work the filegrc queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
292
296
  programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
293
297
  ];
@@ -309,8 +313,53 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
309
313
  if (stage.id === "run") return renderObligations(main, params);
310
314
  const progress = stageProgress(stage);
311
315
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
312
- '<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
313
- renderStagePageIndex(stage) + '</div>';
316
+ '<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>' +
317
+ renderStagePageIndex(stage) + (stage.id === "controls" ? renderEvidenceReadiness() : "") + '</div>';
318
+ }
319
+
320
+ function renderEvidenceReadiness() {
321
+ const items = state.programReadiness?.stages
322
+ ?.find((stage) => stage.id === "controls")
323
+ ?.items.filter((item) => item.id.startsWith("source-family-")) || [];
324
+ const completeCount = items.filter((item) => item.status === "complete").length;
325
+ const cards = items.map((item) => {
326
+ const sources = (item.sourceSystemIds || []).map((id) => {
327
+ const source = state.resources.find(({ record }) => record.type === "system" && record.id === id)?.record;
328
+ const sourceCheck = (item.sourceSystemChecks || []).find(({ sourceSystemId }) => sourceSystemId === id);
329
+ const complete = sourceCheck?.complete ?? (item.completeSourceSystemIds || []).includes(id);
330
+ const status = complete
331
+ ? "Ready"
332
+ : Object.entries(sourceCheck?.checks || {})
333
+ .filter(([, passed]) => !passed)
334
+ .map(([name]) => evidenceSourceCheckLabel(name))
335
+ .join(", ") || "Needs details";
336
+ return source
337
+ ? '<a class="evidence-map-source ' + (complete ? "complete" : "incomplete") + '" href="#/resource/system/' + encodeURIComponent(id) + '">' + esc(source.title) + '<small>' + esc(status) + '</small></a>'
338
+ : "";
339
+ }).join("");
340
+ const sourceAction = sources
341
+ ? sources
342
+ : '<a class="button" href="#/resources/system?new=1">Add source System</a>';
343
+ const method = item.operationRecordTypes?.length
344
+ ? "FileGRC records: " + item.operationRecordTypes.map(properCase).join(", ")
345
+ : properCase(item.evidenceForm || "External evidence");
346
+ 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>' +
347
+ (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>' : "") +
348
+ (item.evidencePrompt ? '<div class="evidence-map-expectation"><strong>Expected evidence</strong><span>' + esc(item.evidencePrompt) + '</span></div>' : "") +
349
+ (item.timing ? '<div class="evidence-map-expectation"><strong>When</strong><span>' + esc(item.timing) + '</span></div>' : "") +
350
+ '<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>';
351
+ }).join("");
352
+ 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>';
353
+ 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>Before marking Controls implemented, connect them to the Systems where their evidence originates. Each source must be active, have the required source role and current access owners, and include repeatable retrieval instructions in Record Markdown.</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>';
354
+ }
355
+
356
+ function evidenceSourceCheckLabel(name) {
357
+ return ({
358
+ active: "activate source",
359
+ sourceRole: "add source role",
360
+ accessOwners: "add access owner",
361
+ retrievalInstructions: "add retrieval instructions"
362
+ })[name] || humanize(name);
314
363
  }
315
364
 
316
365
  function renderStagePageIndex(stage) {
@@ -331,7 +380,7 @@ function stagePageId(stage, destination) {
331
380
 
332
381
  function stagePageComplete(pageId) {
333
382
  const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
334
- return [pageId, ...(STAGE_PAGE_ID_ALIASES[pageId] || [])].some((id) => completedPageIds.includes(id));
383
+ return completedPageIds.includes(pageId);
335
384
  }
336
385
 
337
386
  function stagePageSummary(destination) {
@@ -366,9 +415,9 @@ function operationProgress() {
366
415
  const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
367
416
  const asOf = program?.asOf || currentDate();
368
417
  const candidateStarted = goal === "soc-2-type-2"
369
- ? Boolean(program?.target?.candidatePeriodStart && program.target.candidatePeriodStart <= asOf)
418
+ ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
370
419
  : goal === "soc-2-type-1"
371
- ? Boolean(program?.target?.candidateTypeOneAsOf)
420
+ ? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
372
421
  : Boolean(program?.evidenceReady);
373
422
  const overdue = state.obligations.counts.overdue || 0;
374
423
  const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0);
@@ -451,7 +500,8 @@ function resourceRollup(type) {
451
500
  if (!records.length) return { value: "0", label: "No records yet" };
452
501
  const statuses = new Map();
453
502
  records.forEach((record) => {
454
- if (record.status) statuses.set(record.status, (statuses.get(record.status) || 0) + 1);
503
+ const status = displayStatus(record);
504
+ if (status) statuses.set(status, (statuses.get(status) || 0) + 1);
455
505
  });
456
506
  const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
457
507
  return { value: String(records.length), label: statusText || pluralize("record", records.length) };
@@ -470,12 +520,28 @@ function utilityRollup(utility) {
470
520
  }
471
521
 
472
522
  function auditEngagementPrompt(audit = null) {
473
- const hasAuditor = audit?.auditorVendorId || (audit?.auditor && Object.keys(audit.auditor).length);
523
+ const hasAuditor = audit?.auditorVendorId;
474
524
  if (hasAuditor) return "";
475
525
  const heading = audit ? "CPA Firm Not Recorded" : "Optional: Engage a CPA Firm Early";
476
526
  return '<div class="audit-engagement"><div><strong>' + heading + '</strong><p>The program can keep operating while management selects a firm. Engage early when a customer deadline, unusual scope, or other timing risk needs CPA input.</p></div><ul><li>Share the program boundary, goal, and evidence-source plan.</li><li>Keep management candidate dates separate from the firm-agreed report period.</li><li>Create or update the audit record only for a real engagement.</li></ul>' + (!audit ? '<a class="button" href="#/resources/audit?new=1">Create engagement</a>' : "") + '</div>';
477
527
  }
478
528
 
529
+ function renderExternalEvidenceSection() {
530
+ const records = resourcesOfType("evidence").map(({ record }) => record);
531
+ const recent = records.slice(0, 6).map((record) => (
532
+ '<a href="#/resource/evidence/' + encodeURIComponent(record.id) + '"><span><strong>' + esc(record.title) + '</strong><small>' +
533
+ esc(properCase(record.artifactKind || "Evidence")) + (record.collectedOn ? " · " + esc(formatCalendarDate(record.collectedOn)) : "") +
534
+ '</small></span><span class="badge status-' + esc(record.status || "draft") + '">' + esc(properCase(record.status || "draft")) + '</span></a>'
535
+ )).join("");
536
+ const createButton = state.readOnly
537
+ ? ""
538
+ : '<button class="button primary" type="button" data-new-external-evidence>New external evidence</button>';
539
+ 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>Create a record when a real export, report, screenshot, signed file, or approved external reference exists. Select its authoritative source System, link the Controls and operating record it supports, then record collection and verification facts.</p></div><div class="page-actions">' +
540
+ createButton + '<a class="button" href="#/resources/evidence">View all</a></div></div><div class="external-evidence-list">' +
541
+ (recent || empty("No External Evidence has been collected yet. Create it during operation only when a real artifact or approved external reference exists.")) +
542
+ '</div></section>';
543
+ }
544
+
479
545
  function renderObligations(main, params = new URLSearchParams()) {
480
546
  const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
481
547
  const plan = state.obligations;
@@ -497,12 +563,12 @@ function renderObligations(main, params = new URLSearchParams()) {
497
563
  ? '<section class="policy-event-feedback" role="status" aria-live="polite"><span class="status-dot good"></span><div><strong>Work added to the Work Queue</strong><p>' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '</p></div><button class="button" type="button" data-view-added-work>View Work Queue</button><button class="icon-button" type="button" data-dismiss-policy-event-feedback aria-label="Dismiss confirmation">×</button></section>'
498
564
  : "";
499
565
  main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
500
- '<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(stageProgress(stage)) + '</section>' +
566
+ '<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(stageProgress(stage)) + '</section>' +
501
567
  feedback +
502
568
  '<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
503
569
  '<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>This board schedules work linked to ' + scheduledControls + ' of ' + controls.length + ' controls and includes ' + assignedFollowUp + ' open ' + pluralize("Action Item", assignedFollowUp) + '. Triggered Policy Event actions appear here as individual tasks in Upcoming, Due, or Overdue. Other controls operate continuously or per transaction in their source systems and are documented through evidence records. Starter work remains a proposal until its governing policies are effective and at least one linked control is implemented.</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div>' +
504
570
  '<div class="obligation-board">' + sections + '</div>' +
505
- '</section></div>';
571
+ '</section>' + renderExternalEvidenceSection() + '</div>';
506
572
  main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
507
573
  const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
508
574
  if (trigger) openObligationEventDialog(trigger);
@@ -512,6 +578,7 @@ function renderObligations(main, params = new URLSearchParams()) {
512
578
  policyEventFeedback = null;
513
579
  event.currentTarget.closest(".policy-event-feedback")?.remove();
514
580
  });
581
+ main.querySelector("[data-new-external-evidence]")?.addEventListener("click", () => openEditor("evidence"));
515
582
  main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
516
583
  const column = button.closest("[data-obligation-column]");
517
584
  const expanded = button.getAttribute("aria-expanded") === "true";
@@ -549,7 +616,24 @@ function policyEventTrigger(trigger, index) {
549
616
  }
550
617
 
551
618
  function policyEventName(eventType) {
552
- return POLICY_EVENT_NAMES[eventType] || titleCase(humanize(eventType));
619
+ return state.model.policyEvents?.[eventType]?.title || titleCase(humanize(eventType));
620
+ }
621
+
622
+ function rangeCoverage(startsOn, endsOn) {
623
+ return { kind: "range", startsOn, endsOn };
624
+ }
625
+
626
+ function coverageStart(coverage) {
627
+ return coverage?.kind === "as-of" ? coverage.on : coverage?.startsOn;
628
+ }
629
+
630
+ function coverageEnd(coverage) {
631
+ return coverage?.kind === "as-of" ? coverage.on : coverage?.endsOn;
632
+ }
633
+
634
+ function defaultClassificationId() {
635
+ const definitions = state.workspace.classificationDefinitions || {};
636
+ return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
553
637
  }
554
638
 
555
639
  function obligationCard(item, collapsed = false) {
@@ -568,11 +652,11 @@ function obligationCard(item, collapsed = false) {
568
652
  }
569
653
 
570
654
  function obligationCompletionPlan(item) {
571
- const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
655
+ const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
572
656
  if (!currentPeopleForParties(item.ownerIds || []).length) {
573
657
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
574
658
  }
575
- if (["access-review", "backup-test"].includes(type) && !resourcesOfType("system").some(({ record }) => record.inScope && record.status !== "retired")) {
659
+ if (["access-review", "backup-test"].includes(type) && !(state.workspace.systemIds || []).some((id) => state.resources.some(({ record }) => record.id === id && record.status !== "retired"))) {
576
660
  return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
577
661
  }
578
662
  if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
@@ -604,63 +688,64 @@ function obligationCompletionSeed(type, item, obligation) {
604
688
  const date = currentDate();
605
689
  const timestamp = new Date().toISOString();
606
690
  const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
607
- const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired").map(({ record }) => record.id);
691
+ const inScopeSystems = resourcesOfType("system").filter(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired").map(({ record }) => record.id);
608
692
  const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
609
693
  const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
610
694
  const common = { title };
611
695
  if (type === "meeting") {
612
696
  const team = completionTeam(item);
613
- return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledOn: date };
697
+ return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledFor: date, startedAt: timestamp, endedAt: timestamp, attendeeIds: responsiblePeople };
614
698
  }
615
699
  if (type === "policy-review") {
616
- return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", changesRequired: false, periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
700
+ return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, completedOn: date, outcome: "passed", changesRequired: false, coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
617
701
  }
618
702
  if (type === "risk-assessment") {
619
- return { ...common, status: "complete", assessmentDate: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: responsiblePeople, methodology: state.workspace.riskMethodology?.method || "Documented risk methodology", approvedOn: date };
703
+ return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: responsiblePeople, methodology: state.workspace.riskMethodology?.method || "Documented risk methodology", summary: "Assessment completed; link the supporting evidence and resulting risks.", approvedOn: date };
620
704
  }
621
705
  if (type === "attestation") {
622
706
  return { ...common, status: "completed", subjectResourceIds: [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter(Boolean), personId: responsiblePeople[0], attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
623
707
  }
624
708
  if (type === "access-review") {
625
- return { ...common, status: "complete", reviewDate: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
709
+ return { ...common, status: "complete", completedOn: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", approvedByIds: responsiblePeople, approvedOn: date, coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
626
710
  }
627
711
  if (type === "vulnerability-scan") {
628
- return { ...common, status: "complete", scanKind: "vulnerability", scope: "In-scope systems", operatorIds: responsiblePeople, scheduledOn: date, completedAt: timestamp, systemIds: inScopeSystems, resultSummary: "Document the scan result and link findings or evidence." };
712
+ return { ...common, status: "complete", scanKind: "vulnerability", scope: "In-scope systems", operatorIds: responsiblePeople, scheduledFor: date, completedAt: timestamp, systemIds: inScopeSystems, resultSummary: "Document the scan result and link findings or evidence." };
629
713
  }
630
714
  if (type === "penetration-test") {
631
- return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary", periodStart: date, periodEnd: date, ownerIds: responsiblePeople, outcome: "passed", systemIds: inScopeSystems };
715
+ return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary", coverage: rangeCoverage(date, date), ownerIds: responsiblePeople, outcome: "passed", systemIds: inScopeSystems, completedOn: date, reviewerIds: responsiblePeople, reviewedOn: date };
716
+ }
717
+ if (type === "control-test") {
718
+ return { ...common, status: "complete", controlId: obligation.controlIds?.[0] || "", testKinds: [item.activityType || "control-operation"], performedBy: "management", testerIds: responsiblePeople, reviewerIds: responsiblePeople, completedOn: date, reviewedOn: date, outcome: "passed", coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
632
719
  }
633
720
  if (type === "exercise") {
634
- return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response", scheduledOn: date, facilitatorIds: responsiblePeople, objective: item.title, outcome: "passed", systemIds: inScopeSystems, completedAt: timestamp };
721
+ return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response", scheduledFor: date, facilitatorIds: responsiblePeople, objective: item.title, outcome: "passed", systemIds: inScopeSystems, completedAt: timestamp };
635
722
  }
636
723
  if (type === "backup-test") {
637
- return { ...common, status: "passed", systemIds: inScopeSystems, testDate: date, operatorIds: responsiblePeople, outcome: "passed", completedAt: timestamp };
724
+ return { ...common, status: "complete", systemIds: inScopeSystems, scheduledFor: date, operatorIds: responsiblePeople, reviewerIds: responsiblePeople, outcome: "passed", completedAt: timestamp };
638
725
  }
639
726
  if (type === "vendor-review") {
640
- return { ...common, status: "complete", vendorIds: activeVendors, reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
727
+ const eventVendorId = (item.subjectResourceIds || []).find((id) => state.resources.some(({ record }) => record.id === id && record.type === "vendor"));
728
+ return { ...common, status: "complete", vendorId: eventVendorId || activeVendors[0] || "", reviewerIds: responsiblePeople, completedOn: date, decision: "approved", coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
641
729
  }
642
730
  return {
643
731
  ...common,
644
732
  status: "collected",
645
- evidenceKind: item.activityType || "control-operation",
646
- source: "Internal control operation",
733
+ artifactKind: "business-record",
734
+ artifactSubtype: item.activityType || "control-operation",
735
+ sourceKind: "authored-record",
736
+ sourceDescription: "Internal control operation",
647
737
  collectedOn: date,
648
- classification: "Internal",
649
- periodStart: item.dueWindowStart,
650
- periodEnd: item.dueWindowEnd,
738
+ classificationId: defaultClassificationId(),
739
+ coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd),
651
740
  controlIds: item.controlIds || [],
652
741
  sourceResourceIds: [item.obligationId]
653
742
  };
654
743
  }
655
744
 
656
745
  function openObligationEventDialog(trigger) {
657
- const subjectType = trigger.eventType.startsWith("person") || trigger.eventType.includes("person-") ? "person"
658
- : trigger.eventType.startsWith("vendor") ? "vendor"
659
- : trigger.eventType.startsWith("system") ? "system"
660
- : trigger.eventType.includes("incident") ? "incident"
661
- : null;
662
- const subjects = subjectType ? resourcesOfType(subjectType) : [];
663
- const needsTimestamp = trigger.steps.some((step) => Number.isInteger(step.window?.endOffsetHours));
746
+ const subjectTypes = (state.model.policyEvents?.[trigger.eventType]?.subjectRules || []).map(({ resourceType }) => resourceType);
747
+ const subjects = subjectTypes.flatMap((type) => resourcesOfType(type));
748
+ const needsTimestamp = trigger.steps.some((step) => step.window?.precision === "timestamp");
664
749
  const eventField = needsTimestamp
665
750
  ? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
666
751
  : '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
@@ -668,7 +753,7 @@ function openObligationEventDialog(trigger) {
668
753
  dialog.className = "commit-dialog event-dialog";
669
754
  dialog.setAttribute("aria-labelledby", "event-dialog-title");
670
755
  dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Policy event</p><h2 id="event-dialog-title">' + esc(policyEventName(trigger.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This creates one event record and adds ' + trigger.steps.length + ' linked tasks to the Work Queue.</p>' + eventField +
671
- (subjects.length ? '<label><span>Subject</span><select name="subject"><option value="">Select</option>' + subjects.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' : "") +
756
+ (subjects.length ? '<label><span>Subject <small>required</small></span><select name="subject" required><option value="">Select</option>' + subjects.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select></label>' : "") +
672
757
  '<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(trigger.eventType)) + '"></label><div class="event-dialog-steps">' + trigger.steps.map((step) => '<div><strong>' + esc(step.title) + '</strong><small>' + esc(eventStepSummary(step)) + '</small></div>').join("") + '</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">Add Tasks to Work Queue</button></div></form>';
673
758
  document.body.append(dialog);
674
759
  dialog.showModal();
@@ -698,7 +783,7 @@ function openObligationEventDialog(trigger) {
698
783
  name: policyEventName(trigger.eventType),
699
784
  taskCount: created.actions?.length || trigger.steps.length
700
785
  };
701
- state = await fetchJson("/api/state");
786
+ applyMutationState(created);
702
787
  dialog.close();
703
788
  history.replaceState(null, "", "#/stage/run");
704
789
  render();
@@ -714,14 +799,14 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
714
799
  const audits = resourcesOfType("audit");
715
800
  const evidence = resourcesOfType("evidence");
716
801
  const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
717
- .filter((family) => family.collectionTestRequired === false)
802
+ .filter((family) => family.filegrcManaged === true)
718
803
  .flatMap((family) => family.operationRecordTypes || []));
719
804
  const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
720
805
  const requestedAudit = params.get("auditId");
721
806
  const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
722
807
  const today = currentDate();
723
- const start = selected?.periodStart || selected?.typeOneAsOf || today.slice(0, 4) + "-01-01";
724
- const end = selected?.periodEnd || selected?.typeOneAsOf || today;
808
+ const start = coverageStart(selected?.coverage) || today.slice(0, 4) + "-01-01";
809
+ const end = coverageEnd(selected?.coverage) || today;
725
810
  const typeOne = selected?.auditKind === "soc-2-type-1";
726
811
  const draft = !state.git.clean || !selected;
727
812
  const preparation = state.auditPreparations?.[selected?.id || "none"] || state.auditPreparations?.none;
@@ -732,7 +817,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
732
817
  ["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
733
818
  ["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : 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 ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
734
819
  ];
735
- 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>Complete the applicable Step 5 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
820
+ 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>Complete the applicable Step 4 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
736
821
  const dateFields = typeOne
737
822
  ? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
738
823
  : '<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>';
@@ -754,7 +839,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
754
839
  body: JSON.stringify({ auditId: selected.id })
755
840
  });
756
841
  if (!response.ok) throw new Error(await responseMessage(response));
757
- state = await fetchJson("/api/state");
842
+ applyMutationState(await response.json());
758
843
  render();
759
844
  } catch (caught) {
760
845
  error.textContent = caught.message;
@@ -836,7 +921,7 @@ function renderPacketResults(container, result) {
836
921
  metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
837
922
  metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
838
923
  '</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>' +
839
- '<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.evidenceKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
924
+ '<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>';
840
925
  }
841
926
 
842
927
  function obligationPreview(items) {
@@ -893,13 +978,11 @@ function timingText(item) {
893
978
  }
894
979
 
895
980
  function relativeEventWindow(window) {
896
- if (Number.isInteger(window?.endOffsetHours)) {
897
- return window.endOffsetHours === 0 ? "Due at the event time" : "Due within " + window.endOffsetHours + " hours";
898
- }
899
- if (Number.isInteger(window?.endOffsetDays)) {
900
- return window.endOffsetDays === 0 ? "Due on the event date" : "Due within " + window.endOffsetDays + " days";
901
- }
902
- return "Due within 30 days";
981
+ if (!Number.isInteger(window?.dueAfter)) return "Deadline not configured";
982
+ const unit = window.precision === "timestamp" ? "hour" : "day";
983
+ return window.dueAfter === 0
984
+ ? (window.precision === "timestamp" ? "Due at the event time" : "Due on the event date")
985
+ : "Due within " + window.dueAfter + " " + unit + (window.dueAfter === 1 ? "" : "s");
903
986
  }
904
987
 
905
988
  function eventStepSummary(step) {
@@ -915,6 +998,7 @@ function eventStepSummary(step) {
915
998
  function renderList(main, type, params = new URLSearchParams()) {
916
999
  const definition = state.model.resources[type];
917
1000
  if (!definition) return renderNotFound(main);
1001
+ const listStage = readinessStageForType(type);
918
1002
  const entries = resourcesOfType(type);
919
1003
  const requestedPage = Number(params.get("page"));
920
1004
  let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
@@ -934,7 +1018,7 @@ function renderList(main, type, params = new URLSearchParams()) {
934
1018
  const guideTrigger = '<button class="guide-trigger" id="resource-guide-trigger" type="button" aria-label="About ' + esc(definition.pluralTitle) + '" aria-haspopup="dialog" aria-controls="resource-guide" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8"></circle><path d="M7.8 7.5a2.4 2.4 0 1 1 3.25 2.25c-.7.31-1.05.72-1.05 1.5v.25M10 14.5v.1"></path></svg></button>';
935
1019
  const listTools = '<div class="list-tools list-header-tools"><label><span class="sr-only">Filter list</span><input id="list-search" type="search" placeholder="Filter ' + esc(definition.pluralTitle.toLowerCase()) + '"></label>' +
936
1020
  filters.map(({ name, label, values }) => '<select class="field-filter" data-field="' + esc(name) + '" aria-label="Filter by ' + esc(label.toLowerCase()) + '"><option value="">Any ' + esc(properCase(label)) + '</option>' + values.map((value) => '<option value="' + esc(value) + '">' + esc(filterOptionLabel(value)) + '</option>').join("") + '</select>').join("") + '<span id="result-count" aria-live="polite">' + entries.length + ' records</span>' + createButton + '</div>';
937
- main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(readinessStageForType(type)?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
1021
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(listStage?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
938
1022
  '<section class="record-table-wrap"><table class="record-table"><thead><tr><th>' + esc(fieldLabel(type, "title")) + '</th>' + fields.map((name) => '<th>' + esc(fieldLabel(type, name)) + '</th>').join("") + '<th>Git file</th></tr></thead><tbody id="record-rows"></tbody></table></section>' +
939
1023
  '<nav class="pagination list-pagination" aria-label="' + esc(definition.pluralTitle) + ' pages" hidden><button class="button" type="button" data-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-page="next">Next</button></nav></div>';
940
1024
  resourceGuideCleanup = setupResourceGuide(main);
@@ -951,7 +1035,7 @@ function renderList(main, type, params = new URLSearchParams()) {
951
1035
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
952
1036
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
953
1037
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
954
- 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(entry.record[name], name, type)) + '</td>').join("") + '<td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 2) + '">' + empty("No records match this filter.") + '</td></tr>';
1038
+ 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="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 2) + '">' + empty("No records match this filter.") + '</td></tr>';
955
1039
  pagination.hidden = totalPages === 1;
956
1040
  previous.disabled = pageNumber === 1;
957
1041
  next.disabled = pageNumber === totalPages;
@@ -998,12 +1082,17 @@ function renderDetail(main, type, id) {
998
1082
  const entry = resourcesOfType(type).find(({ record }) => record.id === id);
999
1083
  const definition = state.model.resources[type];
1000
1084
  if (!entry || !definition) return renderNotFound(main);
1085
+ if (entry.detailsLoaded === false) {
1086
+ main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record content and file history…</section></div>';
1087
+ loadResourceDetail(type, id);
1088
+ return;
1089
+ }
1001
1090
  const fields = { ...state.model.commonFields, ...definition.fields };
1002
1091
  const recordContent = recordContentDefinition(type);
1003
1092
  const narrative = recordNarrative(entry.record, fields);
1004
1093
  const narrativeNames = new Set(narrative.map(([name]) => name));
1005
1094
  const visible = Object.entries(entry.record).filter(([name]) => (
1006
- !["schemaVersion", "id", "type", "title"].includes(name)
1095
+ !["id", "type", "title"].includes(name)
1007
1096
  && !fields[name]?.content
1008
1097
  && !narrativeNames.has(name)
1009
1098
  ));
@@ -1028,7 +1117,7 @@ function renderDetail(main, type, id) {
1028
1117
  ? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
1029
1118
  : "";
1030
1119
  main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence &amp; Packet</a>' : "") + issueActions + addRecordContentAction + (!state.readOnly ? '<button class="button" id="edit-resource">Edit</button>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain +
1031
- '<aside><section class="panel"><div class="panel-head"><h3>Metadata</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(value, name, type) + '</dd></div>').join("") + '</dl></section>' + resourceConnections(entry) + '<section class="panel"><div class="panel-head"><h3>File History</h3></div>' + (entry.history?.length ? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>' : empty("No committed history for this file.")) + '</section></aside></div></div>';
1120
+ '<aside><section class="panel"><div class="panel-head"><h3>Metadata</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>' + personParticipation(entry) + resourceConnections(entry) + '<section class="panel"><div class="panel-head"><h3>File History</h3></div>' + (entry.history?.length ? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>' : empty("No committed history for this file.")) + '</section></aside></div></div>';
1032
1121
  main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
1033
1122
  main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
1034
1123
  seed: issueSeed("finding", entry.record),
@@ -1045,7 +1134,7 @@ function renderDetail(main, type, id) {
1045
1134
  try {
1046
1135
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
1047
1136
  if (!response.ok) return showError(await responseMessage(response));
1048
- state = await fetchJson("/api/state");
1137
+ applyMutationState(await response.json());
1049
1138
  location.hash = "#/resources/" + encodeURIComponent(type);
1050
1139
  } catch (error) {
1051
1140
  showError(error.message);
@@ -1053,6 +1142,32 @@ function renderDetail(main, type, id) {
1053
1142
  });
1054
1143
  }
1055
1144
 
1145
+ async function loadResourceDetail(type, id) {
1146
+ const key = type + "\0" + id;
1147
+ if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
1148
+ const request = (async () => {
1149
+ try {
1150
+ const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id));
1151
+ if (!response.ok) throw new Error(await responseMessage(response));
1152
+ const detail = await response.json();
1153
+ const index = state.resources.findIndex(({ record }) => record.type === type && record.id === id);
1154
+ if (index >= 0) state.resources[index] = detail;
1155
+ const route = parseRoute();
1156
+ if (route.name === "detail" && route.type === type && route.id === id) render();
1157
+ } catch (error) {
1158
+ const route = parseRoute();
1159
+ if (route.name === "detail" && route.type === type && route.id === id) {
1160
+ const main = root.querySelector("main");
1161
+ if (main) main.innerHTML = '<div class="page"><section class="panel"><div class="dialog-error" role="alert">' + esc(error.message) + '</div></section></div>';
1162
+ }
1163
+ } finally {
1164
+ resourceDetailRequests.delete(key);
1165
+ }
1166
+ })();
1167
+ resourceDetailRequests.set(key, request);
1168
+ return request;
1169
+ }
1170
+
1056
1171
  function issueSeed(type, source) {
1057
1172
  const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
1058
1173
  if (type === "finding") {
@@ -1091,11 +1206,67 @@ function recordContentDefinition(type) {
1091
1206
  };
1092
1207
  }
1093
1208
 
1209
+ function personParticipation(entry) {
1210
+ if (entry.record.type !== "person") return "";
1211
+ const personId = entry.record.id;
1212
+ const appointments = resourcesOfType("appointment")
1213
+ .map(({ record }) => record)
1214
+ .filter(({ holderId }) => holderId === personId);
1215
+ const appointmentIds = new Set(appointments.map(({ id }) => id));
1216
+ const affiliations = appointments.map((record) => ({
1217
+ record,
1218
+ detail: "Appointment · " + properCase(record.status)
1219
+ }));
1220
+ for (const { record } of resourcesOfType("team")) {
1221
+ const member = (record.memberIds || []).includes(personId);
1222
+ const chair = (record.chairIds || []).some((id) => id === personId || appointmentIds.has(id));
1223
+ if (member || chair) affiliations.push({
1224
+ record,
1225
+ detail: "Team · " + (chair ? "Chair" : "Member")
1226
+ });
1227
+ }
1228
+ const assignments = [];
1229
+ for (const candidate of state.resources) {
1230
+ if (["person", "appointment", "team"].includes(candidate.record.type)) continue;
1231
+ const fields = { ...state.model.commonFields, ...state.model.resources[candidate.record.type].fields };
1232
+ const reasons = [];
1233
+ for (const [name, field] of Object.entries(fields)) {
1234
+ if (!field.relation) continue;
1235
+ const values = Array.isArray(candidate.record[name]) ? candidate.record[name] : [candidate.record[name]];
1236
+ if (values.includes(personId)) reasons.push(fieldLabel(candidate.record.type, name));
1237
+ for (const appointment of appointments) {
1238
+ if (values.includes(appointment.id)) reasons.push(fieldLabel(candidate.record.type, name) + " via " + appointment.title);
1239
+ }
1240
+ }
1241
+ if (reasons.length) assignments.push({
1242
+ record: candidate.record,
1243
+ detail: [...new Set(reasons)].join(" · ")
1244
+ });
1245
+ }
1246
+ const count = affiliations.length + assignments.length;
1247
+ if (!count) return "";
1248
+ return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
1249
+ + personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
1250
+ + personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
1251
+ + '</section>';
1252
+ }
1253
+
1254
+ function personParticipationGroup(title, rows, limit, moreLabel) {
1255
+ if (!rows.length) return "";
1256
+ const visible = rows.slice(0, limit);
1257
+ return '<div class="connection-group"><h4>' + esc(title) + '</h4><div class="connections">'
1258
+ + visible.map(({ record, detail }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '"><strong>' + esc(record.title) + '</strong><small>' + esc(detail) + '</small></a>').join("")
1259
+ + '</div>' + (rows.length > visible.length ? '<p class="connections-more">' + (rows.length - visible.length) + ' ' + esc(moreLabel) + ' are available through connected records.</p>' : "")
1260
+ + '</div>';
1261
+ }
1262
+
1094
1263
  function resourceConnections(entry) {
1264
+ const relatedPeopleOnly = entry.record.type === "person";
1095
1265
  const connections = new Map();
1096
1266
  const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
1097
1267
  const add = (connectedEntry, reason) => {
1098
1268
  if (!connectedEntry || connectedEntry.record.id === entry.record.id) return;
1269
+ if (relatedPeopleOnly && connectedEntry.record.type !== "person") return;
1099
1270
  const existing = connections.get(connectedEntry.record.id) || { entry: connectedEntry, reasons: new Set() };
1100
1271
  existing.reasons.add(reason);
1101
1272
  connections.set(connectedEntry.record.id, existing);
@@ -1125,7 +1296,7 @@ function resourceConnections(entry) {
1125
1296
  });
1126
1297
  if (!sorted.length) return "";
1127
1298
  const visible = sorted.slice(0, 14);
1128
- return '<section class="panel connections-panel"><div class="panel-head"><h3>Connections</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
1299
+ return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
1129
1300
  }
1130
1301
 
1131
1302
  function navigationResourceTypes() {
@@ -1235,7 +1406,7 @@ async function runRepositoryGitAction(action) {
1235
1406
  const response = await localFetch("/api/git/" + action, { method: "POST" });
1236
1407
  if (!response.ok) throw new Error(await responseMessage(response));
1237
1408
  const result = await response.json();
1238
- state = await fetchJson("/api/state");
1409
+ applyMutationState(result);
1239
1410
  render();
1240
1411
  const currentStatus = document.querySelector(".repository-sync-status");
1241
1412
  if (currentStatus) currentStatus.textContent = action === "pull"
@@ -1283,7 +1454,7 @@ function openCommitDialog() {
1283
1454
  });
1284
1455
  if (!response.ok) throw new Error(await responseMessage(response));
1285
1456
  const result = await response.json();
1286
- state = await fetchJson("/api/state");
1457
+ applyMutationState(result);
1287
1458
  dialog.close();
1288
1459
  render();
1289
1460
  const status = document.querySelector(".repository-sync-status");
@@ -1429,12 +1600,13 @@ function requestOnboarding() {
1429
1600
  onboardingDialog = null;
1430
1601
  onboardingDraft = null;
1431
1602
  onboardingBusy = false;
1603
+ onboardingPendingDraft = false;
1432
1604
  });
1433
1605
  renderOnboardingStep();
1434
1606
  }
1435
1607
 
1436
1608
  function initialOnboardingDraft() {
1437
- const systemEntry = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired");
1609
+ const systemEntry = resourcesOfType("system").find(({ record }) => (state.workspace.systemIds || []).includes(record.id) && record.status !== "retired");
1438
1610
  const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
1439
1611
  return {
1440
1612
  systemId: systemEntry?.record.id || "",
@@ -1442,7 +1614,7 @@ function initialOnboardingDraft() {
1442
1614
  scope: systemEntry?.record.description || "",
1443
1615
  ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
1444
1616
  criticality: systemEntry?.record.criticality || "high",
1445
- dataClassification: systemEntry?.record.dataClassification || "Confidential",
1617
+ classificationId: systemEntry?.record.classificationId || defaultClassificationId(),
1446
1618
  internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
1447
1619
  programGoal: programGoalFromKind(state.workspace.assuranceGoal)
1448
1620
  };
@@ -1457,7 +1629,7 @@ function onboardingSteps() {
1457
1629
  points: [
1458
1630
  "Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
1459
1631
  "JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
1460
- "In trunk mode, each browser save fast-forwards, validates, creates one focused commit, and pushes it.",
1632
+ "In trunk mode, each browser save fast-forwards, validates, creates one focused local commit, then pushes it in the background. The workspace stays read-only until sync finishes.",
1461
1633
  "Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
1462
1634
  "Agents and terminal users continue to manage Git explicitly.",
1463
1635
  "The dashboard derives program status from the current repository state."
@@ -1469,7 +1641,7 @@ function onboardingSteps() {
1469
1641
  title: "Follow the audit chain",
1470
1642
  body: "The shortest dependable path is to define scope, approve policies, implement controls, prepare the evidence process, and operate the program before audit fieldwork begins.",
1471
1643
  points: [
1472
- "Scope starts with the people and oversight team, applicable criteria, commitments, material vendors, and in-scope systems.",
1644
+ "Scope starts with people, dated appointments, oversight teams, applicable criteria, commitments, material vendors, and in-scope systems.",
1473
1645
  "Program operation includes current risk assessments and risks, which may add or change controls as conditions change.",
1474
1646
  "Evidence preparation means cataloging authoritative systems, documenting extraction, and testing captures before the candidate period.",
1475
1647
  "The CPA firm, formal report period, fieldwork, and final report are the last stage. Engage earlier only when timing or scope needs outside input."
@@ -1490,7 +1662,7 @@ function onboardingSteps() {
1490
1662
  target: ".event-reminder-panel",
1491
1663
  kicker: "Triggered work",
1492
1664
  title: "Complete a checklist when key events occur",
1493
- body: "Use an event reminder for a new worker, role change, departure, personal device, vendor change or incident, material system or data-use change, or security incident. One action item is created for every policy requirement, with its own owner, evidence, due range, and cutoff.",
1665
+ body: "Use an event reminder for a new worker, job or responsibility change, departure, personal device, vendor change or incident, material system or data-use change, or security incident. One action item is created for every policy requirement, with its own owner, evidence, due range, and cutoff.",
1494
1666
  points: [
1495
1667
  "The checklist stays open until every action is done and has the requested completion record or evidence.",
1496
1668
  "Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
@@ -1562,7 +1734,7 @@ function renderOnboardingStep() {
1562
1734
  ? onboardingSetupForm()
1563
1735
  : description + explanation + afterSections;
1564
1736
  const finalActions = onboardingStep === steps.length - 1
1565
- ? '<button class="button" type="button" data-onboarding="draft">Save draft</button><button class="button primary" type="button" data-onboarding="next">Complete setup</button>'
1737
+ ? '<span class="onboarding-save-status" role="status" aria-live="polite"></span><button class="button" type="button" data-onboarding="draft">Save draft</button><button class="button primary" type="button" data-onboarding="next">Complete setup</button>'
1566
1738
  : '<button class="button primary" type="button" data-onboarding="next">Next</button>';
1567
1739
  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>';
1568
1740
  onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
@@ -1599,8 +1771,8 @@ function renderOnboardingStep() {
1599
1771
  function onboardingSetupForm() {
1600
1772
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
1601
1773
  const classifications = Object.keys(state.workspace.classificationDefinitions || {});
1602
- if (onboardingDraft.dataClassification && !classifications.includes(onboardingDraft.dataClassification)) {
1603
- classifications.push(onboardingDraft.dataClassification);
1774
+ if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
1775
+ classifications.push(onboardingDraft.classificationId);
1604
1776
  }
1605
1777
  const currentSystem = onboardingDraft.systemId ? state.resources.find(({ record }) => record.id === onboardingDraft.systemId)?.record : null;
1606
1778
  const existing = [
@@ -1608,18 +1780,18 @@ function onboardingSetupForm() {
1608
1780
  "Records a management program goal without creating an audit engagement."
1609
1781
  ].filter(Boolean).join(" ");
1610
1782
  const gitStatus = state.repository?.mode === "trunk"
1611
- ? '<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 commit and push it." : state.repository.message) + '</small></span></div>'
1783
+ ? '<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>'
1612
1784
  : state.git.available && state.git.branch
1613
1785
  ? '<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>'
1614
1786
  : '<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>';
1615
- 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="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "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">' + esc(existing) + ' Save draft marks the service Planned and In scope. It is selected for scope review, but it is not approved or active. ' + (state.repository?.mode === "trunk" ? "The browser saves and synchronizes the related files together." : "Manual mode leaves the files for you to commit.") + ' Complete the remaining Step 1 pages next.</p>';
1787
+ 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">' + esc(existing) + ' Save draft marks the service Planned and selects it in Workspace program scope. It is ready for scope review, but it is not approved or active. ' + (state.repository?.mode === "trunk" ? "The browser saves and synchronizes the related files together." : "Manual mode leaves the files for you to commit.") + ' Complete the remaining Step 1 pages next.</p>';
1616
1788
  }
1617
1789
 
1618
1790
  function captureOnboardingForm() {
1619
1791
  const form = onboardingDialog?.querySelector("#onboarding-setup");
1620
1792
  if (!form) return;
1621
1793
  const data = new FormData(form);
1622
- for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "programGoal"]) {
1794
+ for (const name of ["serviceName", "scope", "ownerId", "criticality", "classificationId", "internetExposed", "programGoal"]) {
1623
1795
  onboardingDraft[name] = String(data.get(name) || "").trim();
1624
1796
  }
1625
1797
  }
@@ -1639,7 +1811,7 @@ async function saveOnboarding(draft = false) {
1639
1811
  boundary: onboardingDraft.scope,
1640
1812
  ownerId: onboardingDraft.ownerId,
1641
1813
  criticality: onboardingDraft.criticality,
1642
- dataClassification: onboardingDraft.dataClassification,
1814
+ classificationId: onboardingDraft.classificationId,
1643
1815
  internetExposed: onboardingDraft.internetExposed === "true",
1644
1816
  programGoal: onboardingDraft.programGoal,
1645
1817
  systemId: onboardingDraft.systemId,
@@ -1647,14 +1819,55 @@ async function saveOnboarding(draft = false) {
1647
1819
  })
1648
1820
  });
1649
1821
  if (!response.ok) throw new Error(await responseMessage(response));
1650
- state = await fetchJson("/api/state");
1822
+ const result = await response.json();
1823
+ applyMutationState(result);
1824
+ if (result.synchronization?.pushError) {
1825
+ onboardingDraft.systemId = result.system?.id || onboardingDraft.systemId;
1826
+ onboardingPendingDraft = draft;
1827
+ setOnboardingBusy(false);
1828
+ showOnboardingError(result.synchronization.pushError, true);
1829
+ return;
1830
+ }
1651
1831
  closeOnboarding();
1652
1832
  history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
1653
1833
  render();
1654
1834
  } catch (error) {
1655
1835
  setOnboardingBusy(false);
1656
- const errorNode = onboardingDialog?.querySelector(".dialog-error");
1657
- if (errorNode) errorNode.textContent = error.message;
1836
+ showOnboardingError(error.message);
1837
+ }
1838
+ }
1839
+
1840
+ function showOnboardingError(message, retrySync = false) {
1841
+ const errorNode = onboardingDialog?.querySelector(".dialog-error");
1842
+ if (!errorNode) return;
1843
+ errorNode.textContent = message;
1844
+ if (!retrySync) return;
1845
+ onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
1846
+ const retry = document.createElement("button");
1847
+ retry.type = "button";
1848
+ retry.className = "button onboarding-retry-sync";
1849
+ retry.textContent = "Retry sync";
1850
+ retry.disabled = false;
1851
+ retry.addEventListener("click", retryOnboardingSync);
1852
+ errorNode.append(document.createElement("br"), retry);
1853
+ }
1854
+
1855
+ async function retryOnboardingSync() {
1856
+ if (onboardingBusy) return;
1857
+ setOnboardingBusy(true, "Retrying sync…");
1858
+ try {
1859
+ const response = await localFetch("/api/git/retry-sync", { method: "POST" });
1860
+ if (!response.ok) throw new Error(await responseMessage(response));
1861
+ const result = await response.json();
1862
+ if (!result.state) throw new Error("The sync response did not include the current workspace state.");
1863
+ state = result.state;
1864
+ const draft = onboardingPendingDraft;
1865
+ closeOnboarding();
1866
+ history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
1867
+ render();
1868
+ } catch (error) {
1869
+ setOnboardingBusy(false);
1870
+ showOnboardingError(error.message, true);
1658
1871
  }
1659
1872
  }
1660
1873
 
@@ -1675,8 +1888,7 @@ async function cancelOnboarding() {
1675
1888
  async function persistOnboardingPreference(showOnboarding) {
1676
1889
  const entry = rendererSettingsEntry();
1677
1890
  if (!entry) throw new Error("Renderer settings are unavailable.");
1678
- await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry);
1679
- state = await fetchJson("/api/state");
1891
+ applyMutationState(await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry));
1680
1892
  }
1681
1893
 
1682
1894
  async function writeRendererSettingsResource(record, entry) {
@@ -1689,6 +1901,7 @@ async function writeRendererSettingsResource(record, entry) {
1689
1901
  body: JSON.stringify({ record, revision: entry?.revision })
1690
1902
  });
1691
1903
  if (!response.ok) throw new Error(await responseMessage(response));
1904
+ return response.json();
1692
1905
  }
1693
1906
 
1694
1907
  async function toggleStagePageCompletion(button) {
@@ -1698,25 +1911,31 @@ async function toggleStagePageCompletion(button) {
1698
1911
  const pageId = button.dataset.stagePageCompletion;
1699
1912
  if (button.dataset.complete === "true") {
1700
1913
  completed.delete(pageId);
1701
- (STAGE_PAGE_ID_ALIASES[pageId] || []).forEach((id) => completed.delete(id));
1702
1914
  } else {
1703
1915
  completed.add(pageId);
1704
1916
  }
1705
- await writeRendererSettingsResource({
1917
+ applyMutationState(await writeRendererSettingsResource({
1706
1918
  ...entry.record,
1707
1919
  completedStagePageIds: [...completed].sort()
1708
- }, entry);
1709
- state = await fetchJson("/api/state");
1920
+ }, entry));
1710
1921
  }
1711
1922
 
1712
1923
  function setOnboardingBusy(busy, label = "") {
1713
1924
  if (!onboardingDialog) return;
1925
+ clearTimeout(onboardingStillWorkingTimer);
1926
+ onboardingStillWorkingTimer = null;
1714
1927
  onboardingBusy = busy;
1715
1928
  onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
1716
1929
  const next = onboardingDialog.querySelector('[data-onboarding="next"]');
1717
1930
  if (next && label) next.textContent = label;
1718
1931
  const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
1719
1932
  if (skip && label && onboardingStep !== onboardingSteps().length - 1) skip.textContent = label;
1933
+ if (busy && onboardingStep === onboardingSteps().length - 1) {
1934
+ onboardingStillWorkingTimer = setTimeout(() => {
1935
+ const status = onboardingDialog?.querySelector(".onboarding-save-status");
1936
+ if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
1937
+ }, 1_500);
1938
+ }
1720
1939
  if (!busy) renderOnboardingStep();
1721
1940
  }
1722
1941
 
@@ -1809,6 +2028,8 @@ function clearOnboardingFocus() {
1809
2028
 
1810
2029
  function closeOnboarding() {
1811
2030
  if (!onboardingDialog) return;
2031
+ clearTimeout(onboardingStillWorkingTimer);
2032
+ onboardingStillWorkingTimer = null;
1812
2033
  onboardingBusy = false;
1813
2034
  clearOnboardingFocus();
1814
2035
  onboardingDialog.close();
@@ -1845,9 +2066,10 @@ function openEditor(type, entry = null, options = {}) {
1845
2066
  "title",
1846
2067
  ...required,
1847
2068
  ...(definition.listFields || []),
2069
+ ...(definition.formFields || []),
1848
2070
  ...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
1849
2071
  ...oneOf
1850
- ])].filter((name) => !["schemaVersion", "id", "type"].includes(name) && fields[name]);
2072
+ ])].filter((name) => !["id", "type"].includes(name) && fields[name]);
1851
2073
  const dialog = document.createElement("dialog");
1852
2074
  dialog.className = "editor";
1853
2075
  dialog.setAttribute("aria-labelledby", "resource-editor-title");
@@ -1856,7 +2078,10 @@ function openEditor(type, entry = null, options = {}) {
1856
2078
  ));
1857
2079
  const recordContent = recordContentDefinition(type);
1858
2080
  const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
1859
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : 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(options.description || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.") + '</p><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>' +
2081
+ const editorDescription = options.description
2082
+ || implementationEditorDescription(type)
2083
+ || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.";
2084
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : 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><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>' +
1860
2085
  activeMarkdown.map((markdown) => {
1861
2086
  const generated = !entry?.content?.[markdown.name];
1862
2087
  const source = entry?.content?.[markdown.name]?.source
@@ -1869,7 +2094,7 @@ function openEditor(type, entry = null, options = {}) {
1869
2094
  : "";
1870
2095
  return '<label class="content-editor-field" data-content-editor="' + esc(markdown.name) + '"><span>' + esc(markdown.label) + ' Markdown' + requiredMark + '</span><textarea data-markdown-slot="' + esc(markdown.name) + '" data-generated-content="' + generated + '" spellcheck="true" ' + (requiredNow ? "required" : "") + '>' + esc(source) + '</textarea></label>';
1871
2096
  }).join("") + renderRecordContentEditor(type, entry, options) +
1872
- '<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
2097
+ '<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
1873
2098
  document.body.append(dialog);
1874
2099
  dialog.showModal();
1875
2100
  dialog.addEventListener("close", () => dialog.remove());
@@ -1900,6 +2125,7 @@ function openEditor(type, entry = null, options = {}) {
1900
2125
  }
1901
2126
  dialog.querySelector("form").addEventListener("submit", async (event) => {
1902
2127
  event.preventDefault();
2128
+ if (dialog.dataset.mutationBusy === "true") return;
1903
2129
  try {
1904
2130
  const advanced = dialog.dataset.jsonDirty === "true";
1905
2131
  if (!advanced && !dialog.querySelector("form").reportValidity()) return;
@@ -1930,6 +2156,7 @@ function openEditor(type, entry = null, options = {}) {
1930
2156
  ...activeMarkdown.map(({ name }) => [entry?.content?.[name]?.path, entry?.content?.[name]?.revision]),
1931
2157
  [recordContentItem?.path, recordContentItem?.revision]
1932
2158
  ].filter(([path, revision]) => path && revision));
2159
+ setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
1933
2160
  const response = await localFetch(url, {
1934
2161
  method: entry ? "PUT" : "POST",
1935
2162
  headers: { "content-type": "application/json" },
@@ -1942,18 +2169,29 @@ function openEditor(type, entry = null, options = {}) {
1942
2169
  })
1943
2170
  });
1944
2171
  if (!response.ok) throw new Error(await responseMessage(response));
1945
- state = await fetchJson("/api/state");
2172
+ applyMutationState(await response.json());
1946
2173
  dialog.close();
1947
2174
  location.hash = "#/resource/" + encodeURIComponent(updated.type) + "/" + encodeURIComponent(updated.id);
1948
2175
  render();
1949
2176
  } catch (error) {
2177
+ setMutationBusy(dialog, false, "", options.saveLabel || "Save file");
1950
2178
  dialog.querySelector(".dialog-error").textContent = error.message;
1951
2179
  }
1952
2180
  });
1953
2181
  }
1954
2182
 
2183
+ function implementationEditorDescription(type) {
2184
+ if (type === "system") {
2185
+ 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.";
2186
+ }
2187
+ if (type === "control") {
2188
+ 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.";
2189
+ }
2190
+ return "";
2191
+ }
2192
+
1955
2193
  function seedRecord(type, definition) {
1956
- const record = { schemaVersion: 1, id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
2194
+ const record = { id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
1957
2195
  const fields = { ...state.model.commonFields, ...definition.fields };
1958
2196
  for (const name of definition.required || []) {
1959
2197
  const field = fields[name];
@@ -2028,20 +2266,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
2028
2266
  : field.relation ? relationHelp(field)
2029
2267
  : "";
2030
2268
  let control;
2269
+ if (field.managed) {
2270
+ control = '<textarea readonly spellcheck="false" placeholder="Filled when approval is saved">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
2271
+ return fieldWrap(name, "object", label, requiredMark, control, "Managed by filegrc from the exact companion Markdown revisions", false);
2272
+ }
2031
2273
  if (field.relation && field.type === "array") {
2032
2274
  const candidates = relationCandidates(field);
2033
2275
  control = candidates.length
2034
- ? '<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(record.id) + '</small></span></label>').join("") + '</div>'
2276
+ ? '<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>'
2035
2277
  : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
2036
2278
  return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
2037
2279
  }
2038
2280
  if (field.relation) {
2039
2281
  const candidates = relationCandidates(field);
2040
2282
  control = candidates.length
2041
- ? '<select><option value="">Select a Resource</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(record.id) + '</option>').join("") + '</select>'
2283
+ ? '<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>'
2042
2284
  : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
2043
2285
  return fieldWrap(name, "relation", label, requiredMark, control, help, required);
2044
2286
  }
2287
+ if (name === "classificationId") {
2288
+ const values = Object.keys(state.workspace.classificationDefinitions || {});
2289
+ control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
2290
+ return fieldWrap(name, "string", label, requiredMark, control, "Defined by Workspace classificationDefinitions", required);
2291
+ }
2045
2292
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
2046
2293
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
2047
2294
  control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
@@ -2057,7 +2304,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
2057
2304
  }
2058
2305
  if (field.type === "array") {
2059
2306
  control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
2060
- return fieldWrap(name, "array", label, requiredMark, control, "One value per line", required);
2307
+ const arrayHelp = name === "evidenceSourceKinds"
2308
+ ? "One role per line. Use the roles required by the Controls this System supports, such as " + evidenceSourceRoleOptions().join(", ") + "."
2309
+ : "One value per line";
2310
+ return fieldWrap(name, "array", label, requiredMark, control, arrayHelp, required);
2061
2311
  }
2062
2312
  if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
2063
2313
  control = '<textarea>' + esc(value ?? "") + '</textarea>';
@@ -2071,6 +2321,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
2071
2321
  return fieldWrap(name, field.type, label, requiredMark, control, help, required);
2072
2322
  }
2073
2323
 
2324
+ function evidenceSourceRoleOptions() {
2325
+ return [...new Set((state.model.evidenceSourceFamilies || []).flatMap(({ sourceKinds }) => sourceKinds || []))].sort();
2326
+ }
2327
+
2074
2328
  function fieldWrap(name, kind, label, requiredMark, control, help, required) {
2075
2329
  const labelId = "field-label-" + name;
2076
2330
  let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
@@ -2111,14 +2365,28 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups, markdownDefin
2111
2365
  };
2112
2366
  const refresh = () => {
2113
2367
  for (const [name, field] of Object.entries(fields)) {
2114
- if (!field.requiredWhen) continue;
2115
2368
  const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
2116
2369
  if (!group) continue;
2370
+ if (field.managed) {
2371
+ const visible = !field.allowedWhen || conditionMatchesValues(field.allowedWhen, currentValue);
2372
+ group.hidden = !visible;
2373
+ refreshGroup(group, false);
2374
+ continue;
2375
+ }
2376
+ if (field.allowedWhen && !conditionMatchesValues(field.allowedWhen, currentValue)) {
2377
+ group.hidden = true;
2378
+ refreshGroup(group, false);
2379
+ continue;
2380
+ }
2381
+ if (!field.requiredWhen) {
2382
+ group.hidden = false;
2383
+ continue;
2384
+ }
2117
2385
  const visible = !field.visibleWhen || conditionMatchesValues(field.visibleWhen, currentValue);
2118
2386
  const applicable = Object.entries(field.requiredWhen)
2119
2387
  .filter(([conditionName]) => conditionName !== "status")
2120
2388
  .every(([conditionName, expected]) => conditionValueMatches(currentValue(conditionName), expected));
2121
- group.hidden = !visible || !applicable;
2389
+ group.hidden = !visible || (!applicable && field.showWhenInactive !== true);
2122
2390
  const required = visible && applicable && conditionMatchesValues(field.requiredWhen, currentValue);
2123
2391
  refreshGroup(group, required);
2124
2392
  }
@@ -2176,6 +2444,10 @@ function readGuidedRecord(dialog, base, fields) {
2176
2444
  const record = structuredClone(base);
2177
2445
  for (const group of dialog.querySelectorAll("[data-field-group]")) {
2178
2446
  const name = group.dataset.fieldGroup;
2447
+ if (group.hidden) {
2448
+ delete record[name];
2449
+ continue;
2450
+ }
2179
2451
  const kind = group.dataset.kind;
2180
2452
  let value;
2181
2453
  if (kind === "relation-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
@@ -2200,7 +2472,11 @@ function relationCandidates(field) {
2200
2472
  }
2201
2473
 
2202
2474
  function relationHelp(field) {
2203
- return "References " + (field.relation.includes("*") ? "any resource" : field.relation.map((type) => state.model.resources[type]?.pluralTitle || type).join(" or "));
2475
+ if (field.relation.includes("*")) return "References any resource";
2476
+ const labels = field.relation.map((type) => state.model.resources[type]?.pluralTitle || type);
2477
+ if (labels.length < 2) return "References " + labels.join("");
2478
+ if (labels.length === 2) return "References " + labels.join(" or ");
2479
+ return "References " + labels.slice(0, -1).join(", ") + ", or " + labels.at(-1);
2204
2480
  }
2205
2481
 
2206
2482
  function openContentEditor(entry, name) {
@@ -2209,18 +2485,21 @@ function openContentEditor(entry, name) {
2209
2485
  const dialog = document.createElement("dialog");
2210
2486
  dialog.className = "editor content-dialog";
2211
2487
  dialog.setAttribute("aria-labelledby", "content-editor-title");
2212
- dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
2488
+ dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
2213
2489
  document.body.append(dialog);
2214
2490
  dialog.showModal();
2215
2491
  dialog.addEventListener("close", () => dialog.remove());
2216
2492
  dialog.querySelector("#save-content").addEventListener("click", async () => {
2493
+ if (dialog.dataset.mutationBusy === "true") return;
2217
2494
  try {
2495
+ setMutationBusy(dialog, true, "Saving…", "Save Markdown");
2218
2496
  const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision }) });
2219
2497
  if (!response.ok) throw new Error(await responseMessage(response));
2220
- state = await fetchJson("/api/state");
2498
+ applyMutationState(await response.json());
2221
2499
  dialog.close();
2222
2500
  render();
2223
2501
  } catch (error) {
2502
+ setMutationBusy(dialog, false, "", "Save Markdown");
2224
2503
  dialog.querySelector(".dialog-error").textContent = error.message;
2225
2504
  }
2226
2505
  });
@@ -2356,7 +2635,11 @@ function metric(label, value, note, tone) {
2356
2635
  }
2357
2636
  function countOverdue(entries) { const today = currentDate(); return entries.filter(({ record }) => dueDate(record) && dueDate(record) < today).length; }
2358
2637
  function dueDate(record) {
2359
- const explicit = record.dueOn || record.nextDueOn || record.reviewDueOn || record.expiresOn || record.acceptanceExpiresOn || record.scheduledOn;
2638
+ const explicit = record.completionWindow?.dueOn
2639
+ || record.completionWindow?.dueAt?.slice(0, 10)
2640
+ || record.dueOn
2641
+ || record.expiresOn
2642
+ || record.scheduledFor;
2360
2643
  if (explicit) return explicit;
2361
2644
  if (record.type !== "obligation" || record.status !== "active") return null;
2362
2645
  const recurrence = record.recurrence?.anchorDate
@@ -2396,6 +2679,9 @@ function currentPeopleForParties(ids = [], seen = new Set()) {
2396
2679
  if (party?.type === "team" && party.status === "active") {
2397
2680
  people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
2398
2681
  }
2682
+ if (party?.type === "appointment" && party.status === "active") {
2683
+ people.push(...currentPeopleForParties([party.holderId], seen));
2684
+ }
2399
2685
  }
2400
2686
  return [...new Set(people)];
2401
2687
  }
@@ -2463,6 +2749,19 @@ function fieldLabel(type, name) {
2463
2749
  function filterOptionLabel(value) { return state.resources.find(({ record }) => record.id === value)?.record.title || properCase(value); }
2464
2750
  function humanize(value) { return String(value).replace(/[-_]+/g, " ").replace(/Ids?$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (letter) => letter.toUpperCase()); }
2465
2751
  function properCase(value) { return humanize(value).replace(/\b[a-z]/g, (letter) => letter.toUpperCase()).replace(/\bSoc 2\b/g, "SOC 2"); }
2752
+ function displayStatus(record) {
2753
+ if (record?.type === "attestation"
2754
+ && record.status === "pending"
2755
+ && record.dueOn
2756
+ && state.asOf
2757
+ && record.dueOn < state.asOf) return "overdue";
2758
+ if (record?.type === "evidence"
2759
+ && ["collected", "verified"].includes(record.status)
2760
+ && record.expiresOn
2761
+ && state.asOf
2762
+ && record.expiresOn < state.asOf) return "expired";
2763
+ return record?.status;
2764
+ }
2466
2765
  function titleCase(value) {
2467
2766
  const words = String(value).split(/\s+/);
2468
2767
  return words.map((word, index) => {
@@ -2588,8 +2887,82 @@ ${nextCalendarOccurrence.toString()}
2588
2887
  ${formatCalendarDate.toString()}
2589
2888
  ${formatLocalDateTime.toString()}
2590
2889
  function empty(message) { return '<div class="empty">' + esc(message) + '</div>'; }
2591
- function pluralize(noun, count) { return count === 1 ? noun : noun + "s"; }
2890
+ function pluralize(noun, count) {
2891
+ if (count === 1) return noun;
2892
+ if (/[^aeiou]y$/i.test(noun)) return noun.slice(0, -1) + "ies";
2893
+ return noun + "s";
2894
+ }
2592
2895
  function renderNotFound(main) { main.innerHTML = '<div class="page">' + empty("That resource does not exist.") + '</div>'; }
2896
+ function applyMutationState(result) {
2897
+ if (!result?.state) throw new Error("The save response did not include the current workspace state.");
2898
+ state = result.state;
2899
+ scheduleRepositorySyncPoll(result.synchronization);
2900
+ }
2901
+
2902
+ function scheduleRepositorySyncPoll(synchronization = state.repository?.backgroundSynchronization) {
2903
+ const syncing = synchronization?.status === "syncing"
2904
+ || state.repository?.status === "syncing";
2905
+ if (!syncing) {
2906
+ clearTimeout(repositorySyncPollTimer);
2907
+ repositorySyncPollTimer = null;
2908
+ return;
2909
+ }
2910
+ if (repositorySyncPollTimer || repositorySyncPollInFlight) return;
2911
+ repositorySyncPollTimer = setTimeout(pollRepositorySync, 400);
2912
+ }
2913
+
2914
+ async function pollRepositorySync() {
2915
+ repositorySyncPollTimer = null;
2916
+ if (repositorySyncPollInFlight) return;
2917
+ repositorySyncPollInFlight = true;
2918
+ let continuePolling = false;
2919
+ try {
2920
+ const response = await localFetch("/api/git/sync-status");
2921
+ if (!response.ok) throw new Error(await responseMessage(response));
2922
+ const result = await response.json();
2923
+ const wasSyncing = state.repository?.status === "syncing";
2924
+ state.repository = result.repository;
2925
+ state.git = result.git;
2926
+ state.readOnly = result.readOnly;
2927
+ if (result.repository?.status === "syncing") {
2928
+ continuePolling = true;
2929
+ } else if (wasSyncing) {
2930
+ render();
2931
+ }
2932
+ } catch {
2933
+ continuePolling = true;
2934
+ } finally {
2935
+ repositorySyncPollInFlight = false;
2936
+ }
2937
+ if (continuePolling) {
2938
+ repositorySyncPollTimer = setTimeout(pollRepositorySync, 1_000);
2939
+ }
2940
+ }
2941
+
2942
+ function setMutationBusy(dialog, busy, label, idleLabel) {
2943
+ clearTimeout(dialog._stillWorkingTimer);
2944
+ dialog._stillWorkingTimer = null;
2945
+ dialog.dataset.mutationBusy = busy ? "true" : "false";
2946
+ dialog.querySelectorAll("button,input,select,textarea").forEach((control) => {
2947
+ if (busy) {
2948
+ control.dataset.mutationWasDisabled = control.disabled ? "true" : "false";
2949
+ control.disabled = true;
2950
+ } else if (control.dataset.mutationWasDisabled === "false") {
2951
+ control.disabled = false;
2952
+ delete control.dataset.mutationWasDisabled;
2953
+ }
2954
+ });
2955
+ const button = dialog.querySelector("#save-record,#save-content");
2956
+ if (button) button.textContent = busy ? label : idleLabel;
2957
+ const status = dialog.querySelector(".save-status");
2958
+ if (status) status.textContent = "";
2959
+ if (busy) {
2960
+ dialog._stillWorkingTimer = setTimeout(() => {
2961
+ if (status) status.textContent = "Still working. Git sync and workspace checks can take a moment.";
2962
+ }, 1_500);
2963
+ }
2964
+ }
2965
+
2593
2966
  function showError(message) {
2594
2967
  const dialog = document.createElement("dialog");
2595
2968
  dialog.className = "alert-dialog";
@@ -2653,8 +3026,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
2653
3026
  .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}
2654
3027
  .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)}
2655
3028
  .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}
2656
- .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-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{position:relative;z-index:2;color:var(--accent)}.stage-page-completion.complete{color:#176143;border-color:#b9dac6;background:#edf7f1}.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}
3029
+ .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-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{position:relative;z-index:2;color:var(--accent)}.stage-page-completion.complete{color:#176143;border-color:#b9dac6;background:#edf7f1}.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}
2657
3030
  .button{text-decoration:none}
3031
+ .external-evidence-section{margin-top:28px}.external-evidence-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.external-evidence-list>.empty{grid-column:1/-1}.external-evidence-list>a{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 15px;background:var(--panel);border:1px solid var(--line);border-radius:8px;text-decoration:none}.external-evidence-list>a:hover{border-color:var(--accent-light)}.external-evidence-list>a>span:first-child{min-width:0}.external-evidence-list strong,.external-evidence-list small{display:block}.external-evidence-list strong{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.external-evidence-list small{color:var(--muted);font-size:9.6px;margin-top:4px}
2658
3032
  .home-page{padding-top:16px;padding-bottom:16px}.overview-hero{min-height:72px;padding:10px 20px;align-items:center}.overview-hero h2{font-size:26.4px;margin:3px 0 2px}.overview-hero p:not(.kicker){font-size:12px}.home-page .readiness-map{padding:12px 15px}.home-page .readiness-map-head{margin-bottom:8px}.home-page .readiness-flow a{padding:7px}
2659
3033
  .overview-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:10px}.overview-grid>.audit-panel{grid-column:1/-1}
2660
3034
  .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}
@@ -2666,18 +3040,21 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
2666
3040
  .readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
2667
3041
  .page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
2668
3042
  .operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
2669
- .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
3043
+ .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-sync-alert{display:flex;align-items:flex-start;gap:9px;padding:10px 22px;border-bottom:1px solid #e9c888;background:#fff8e8;color:var(--ink);font-size:12px;line-height:1.45}.repository-sync-alert.syncing{border-color:var(--line);background:var(--surface-soft)}.repository-sync-alert .status-dot{flex:0 0 auto;margin-top:4px}.repository-sync-alert a{margin-left:auto;white-space:nowrap}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;flex-wrap:wrap;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
3044
+ .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)}
3045
+ .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
2670
3046
  .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}
2671
3047
  @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}}
2672
3048
  @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))}}
2673
3049
  @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}}
2674
- @media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid{grid-template-columns:1fr}.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}}
3050
+ @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}}
2675
3051
  @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}}
2676
3052
  @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}}
2677
3053
  @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}}
2678
3054
  @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}}
2679
3055
  @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)}}
2680
3056
 
3057
+ .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}
2681
3058
  body,button,input,select,textarea,dialog{color:var(--ink)}
2682
3059
  button,input,select,textarea{accent-color:var(--accent)}
2683
3060
  :focus-visible{outline:3px solid var(--focus);outline-offset:2px}