filegrc 0.3.3 → 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)
@@ -217,13 +213,34 @@ function topbar(route) {
217
213
  : route.name === "audit-packet"
218
214
  ? "Audit Readiness"
219
215
  : state.model.resources[route.type]?.pluralTitle || "filegrc";
220
- return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + (state.git.clean ? "good" : "warn") + '"></span>' + esc(state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</a></div>';
216
+ const repositoryLabel = state.repository?.mode === "trunk"
217
+ ? state.repository.label
218
+ : state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
219
+ const repositoryTone = state.repository?.mode === "trunk"
220
+ ? repositoryStatusTone(state.repository.status)
221
+ : state.git.clean ? "good" : "warn";
222
+ return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
223
+ }
224
+
225
+ function repositoryStatusTone(status) {
226
+ if (status === "synced") return "good";
227
+ if (status === "syncing") return "neutral";
228
+ return "warn";
229
+ }
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>';
221
238
  }
222
239
 
223
240
  function renderHome(main) {
224
241
  const activeAudit = resourcesOfType("audit").find((item) => !["complete", "closed", "cancelled"].includes(item.record.status));
225
242
  const program = state.programReadiness;
226
- const activeFirm = activeAudit && (activeAudit.record.auditorVendorId || (activeAudit.record.auditor && Object.keys(activeAudit.record.auditor).length));
243
+ const activeFirm = activeAudit?.record.auditorVendorId;
227
244
  const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
228
245
  const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
229
246
  const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
@@ -242,7 +259,7 @@ function renderHome(main) {
242
259
  }
243
260
 
244
261
  function initialSetupBanner() {
245
- 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;
246
263
  if (!system) {
247
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>';
248
265
  }
@@ -274,8 +291,7 @@ function readinessOverview() {
274
291
  const stages = [
275
292
  programStage("scope", "Confirm program ownership, criteria, and commitments, then describe the service, supporting systems, and dependencies.", "#/stage/scope"),
276
293
  programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
277
- programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
278
- 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"),
279
295
  programStage("run", "Begin the candidate period, maintain risk assessments, work the filegrc queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
280
296
  programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
281
297
  ];
@@ -297,8 +313,53 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
297
313
  if (stage.id === "run") return renderObligations(main, params);
298
314
  const progress = stageProgress(stage);
299
315
  main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
300
- '<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>' +
301
- 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);
302
363
  }
303
364
 
304
365
  function renderStagePageIndex(stage) {
@@ -319,7 +380,7 @@ function stagePageId(stage, destination) {
319
380
 
320
381
  function stagePageComplete(pageId) {
321
382
  const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
322
- return [pageId, ...(STAGE_PAGE_ID_ALIASES[pageId] || [])].some((id) => completedPageIds.includes(id));
383
+ return completedPageIds.includes(pageId);
323
384
  }
324
385
 
325
386
  function stagePageSummary(destination) {
@@ -354,9 +415,9 @@ function operationProgress() {
354
415
  const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
355
416
  const asOf = program?.asOf || currentDate();
356
417
  const candidateStarted = goal === "soc-2-type-2"
357
- ? Boolean(program?.target?.candidatePeriodStart && program.target.candidatePeriodStart <= asOf)
418
+ ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
358
419
  : goal === "soc-2-type-1"
359
- ? Boolean(program?.target?.candidateTypeOneAsOf)
420
+ ? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
360
421
  : Boolean(program?.evidenceReady);
361
422
  const overdue = state.obligations.counts.overdue || 0;
362
423
  const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0);
@@ -439,7 +500,8 @@ function resourceRollup(type) {
439
500
  if (!records.length) return { value: "0", label: "No records yet" };
440
501
  const statuses = new Map();
441
502
  records.forEach((record) => {
442
- 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);
443
505
  });
444
506
  const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
445
507
  return { value: String(records.length), label: statusText || pluralize("record", records.length) };
@@ -458,12 +520,28 @@ function utilityRollup(utility) {
458
520
  }
459
521
 
460
522
  function auditEngagementPrompt(audit = null) {
461
- const hasAuditor = audit?.auditorVendorId || (audit?.auditor && Object.keys(audit.auditor).length);
523
+ const hasAuditor = audit?.auditorVendorId;
462
524
  if (hasAuditor) return "";
463
525
  const heading = audit ? "CPA Firm Not Recorded" : "Optional: Engage a CPA Firm Early";
464
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>';
465
527
  }
466
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
+
467
545
  function renderObligations(main, params = new URLSearchParams()) {
468
546
  const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
469
547
  const plan = state.obligations;
@@ -485,12 +563,12 @@ function renderObligations(main, params = new URLSearchParams()) {
485
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>'
486
564
  : "";
487
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>' +
488
- '<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>' +
489
567
  feedback +
490
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>' +
491
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>' +
492
570
  '<div class="obligation-board">' + sections + '</div>' +
493
- '</section></div>';
571
+ '</section>' + renderExternalEvidenceSection() + '</div>';
494
572
  main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
495
573
  const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
496
574
  if (trigger) openObligationEventDialog(trigger);
@@ -500,6 +578,7 @@ function renderObligations(main, params = new URLSearchParams()) {
500
578
  policyEventFeedback = null;
501
579
  event.currentTarget.closest(".policy-event-feedback")?.remove();
502
580
  });
581
+ main.querySelector("[data-new-external-evidence]")?.addEventListener("click", () => openEditor("evidence"));
503
582
  main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
504
583
  const column = button.closest("[data-obligation-column]");
505
584
  const expanded = button.getAttribute("aria-expanded") === "true";
@@ -537,7 +616,24 @@ function policyEventTrigger(trigger, index) {
537
616
  }
538
617
 
539
618
  function policyEventName(eventType) {
540
- 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] || "";
541
637
  }
542
638
 
543
639
  function obligationCard(item, collapsed = false) {
@@ -556,11 +652,11 @@ function obligationCard(item, collapsed = false) {
556
652
  }
557
653
 
558
654
  function obligationCompletionPlan(item) {
559
- const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
655
+ const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
560
656
  if (!currentPeopleForParties(item.ownerIds || []).length) {
561
657
  return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
562
658
  }
563
- 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"))) {
564
660
  return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
565
661
  }
566
662
  if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
@@ -592,63 +688,64 @@ function obligationCompletionSeed(type, item, obligation) {
592
688
  const date = currentDate();
593
689
  const timestamp = new Date().toISOString();
594
690
  const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
595
- 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);
596
692
  const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
597
693
  const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
598
694
  const common = { title };
599
695
  if (type === "meeting") {
600
696
  const team = completionTeam(item);
601
- 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 };
602
698
  }
603
699
  if (type === "policy-review") {
604
- 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) };
605
701
  }
606
702
  if (type === "risk-assessment") {
607
- 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 };
608
704
  }
609
705
  if (type === "attestation") {
610
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" };
611
707
  }
612
708
  if (type === "access-review") {
613
- 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) };
614
710
  }
615
711
  if (type === "vulnerability-scan") {
616
- 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." };
617
713
  }
618
714
  if (type === "penetration-test") {
619
- 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) };
620
719
  }
621
720
  if (type === "exercise") {
622
- 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 };
623
722
  }
624
723
  if (type === "backup-test") {
625
- 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 };
626
725
  }
627
726
  if (type === "vendor-review") {
628
- 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) };
629
729
  }
630
730
  return {
631
731
  ...common,
632
732
  status: "collected",
633
- evidenceKind: item.activityType || "control-operation",
634
- source: "Internal control operation",
733
+ artifactKind: "business-record",
734
+ artifactSubtype: item.activityType || "control-operation",
735
+ sourceKind: "authored-record",
736
+ sourceDescription: "Internal control operation",
635
737
  collectedOn: date,
636
- classification: "Internal",
637
- periodStart: item.dueWindowStart,
638
- periodEnd: item.dueWindowEnd,
738
+ classificationId: defaultClassificationId(),
739
+ coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd),
639
740
  controlIds: item.controlIds || [],
640
741
  sourceResourceIds: [item.obligationId]
641
742
  };
642
743
  }
643
744
 
644
745
  function openObligationEventDialog(trigger) {
645
- const subjectType = trigger.eventType.startsWith("person") || trigger.eventType.includes("person-") ? "person"
646
- : trigger.eventType.startsWith("vendor") ? "vendor"
647
- : trigger.eventType.startsWith("system") ? "system"
648
- : trigger.eventType.includes("incident") ? "incident"
649
- : null;
650
- const subjects = subjectType ? resourcesOfType(subjectType) : [];
651
- 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");
652
749
  const eventField = needsTimestamp
653
750
  ? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
654
751
  : '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
@@ -656,7 +753,7 @@ function openObligationEventDialog(trigger) {
656
753
  dialog.className = "commit-dialog event-dialog";
657
754
  dialog.setAttribute("aria-labelledby", "event-dialog-title");
658
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 +
659
- (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>' : "") +
660
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>';
661
758
  document.body.append(dialog);
662
759
  dialog.showModal();
@@ -686,7 +783,7 @@ function openObligationEventDialog(trigger) {
686
783
  name: policyEventName(trigger.eventType),
687
784
  taskCount: created.actions?.length || trigger.steps.length
688
785
  };
689
- state = await fetchJson("/api/state");
786
+ applyMutationState(created);
690
787
  dialog.close();
691
788
  history.replaceState(null, "", "#/stage/run");
692
789
  render();
@@ -702,14 +799,14 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
702
799
  const audits = resourcesOfType("audit");
703
800
  const evidence = resourcesOfType("evidence");
704
801
  const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
705
- .filter((family) => family.collectionTestRequired === false)
802
+ .filter((family) => family.filegrcManaged === true)
706
803
  .flatMap((family) => family.operationRecordTypes || []));
707
804
  const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
708
805
  const requestedAudit = params.get("auditId");
709
806
  const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
710
807
  const today = currentDate();
711
- const start = selected?.periodStart || selected?.typeOneAsOf || today.slice(0, 4) + "-01-01";
712
- 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;
713
810
  const typeOne = selected?.auditKind === "soc-2-type-1";
714
811
  const draft = !state.git.clean || !selected;
715
812
  const preparation = state.auditPreparations?.[selected?.id || "none"] || state.auditPreparations?.none;
@@ -720,7 +817,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
720
817
  ["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
721
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"]
722
819
  ];
723
- 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>';
724
821
  const dateFields = typeOne
725
822
  ? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
726
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>';
@@ -742,7 +839,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
742
839
  body: JSON.stringify({ auditId: selected.id })
743
840
  });
744
841
  if (!response.ok) throw new Error(await responseMessage(response));
745
- state = await fetchJson("/api/state");
842
+ applyMutationState(await response.json());
746
843
  render();
747
844
  } catch (caught) {
748
845
  error.textContent = caught.message;
@@ -824,7 +921,7 @@ function renderPacketResults(container, result) {
824
921
  metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
825
922
  metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
826
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>' +
827
- '<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>';
828
925
  }
829
926
 
830
927
  function obligationPreview(items) {
@@ -881,13 +978,11 @@ function timingText(item) {
881
978
  }
882
979
 
883
980
  function relativeEventWindow(window) {
884
- if (Number.isInteger(window?.endOffsetHours)) {
885
- return window.endOffsetHours === 0 ? "Due at the event time" : "Due within " + window.endOffsetHours + " hours";
886
- }
887
- if (Number.isInteger(window?.endOffsetDays)) {
888
- return window.endOffsetDays === 0 ? "Due on the event date" : "Due within " + window.endOffsetDays + " days";
889
- }
890
- 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");
891
986
  }
892
987
 
893
988
  function eventStepSummary(step) {
@@ -903,6 +998,7 @@ function eventStepSummary(step) {
903
998
  function renderList(main, type, params = new URLSearchParams()) {
904
999
  const definition = state.model.resources[type];
905
1000
  if (!definition) return renderNotFound(main);
1001
+ const listStage = readinessStageForType(type);
906
1002
  const entries = resourcesOfType(type);
907
1003
  const requestedPage = Number(params.get("page"));
908
1004
  let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
@@ -922,7 +1018,7 @@ function renderList(main, type, params = new URLSearchParams()) {
922
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>';
923
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>' +
924
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>';
925
- 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) +
926
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>' +
927
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>';
928
1024
  resourceGuideCleanup = setupResourceGuide(main);
@@ -939,7 +1035,7 @@ function renderList(main, type, params = new URLSearchParams()) {
939
1035
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
940
1036
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
941
1037
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
942
- 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>';
943
1039
  pagination.hidden = totalPages === 1;
944
1040
  previous.disabled = pageNumber === 1;
945
1041
  next.disabled = pageNumber === totalPages;
@@ -986,12 +1082,17 @@ function renderDetail(main, type, id) {
986
1082
  const entry = resourcesOfType(type).find(({ record }) => record.id === id);
987
1083
  const definition = state.model.resources[type];
988
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
+ }
989
1090
  const fields = { ...state.model.commonFields, ...definition.fields };
990
1091
  const recordContent = recordContentDefinition(type);
991
1092
  const narrative = recordNarrative(entry.record, fields);
992
1093
  const narrativeNames = new Set(narrative.map(([name]) => name));
993
1094
  const visible = Object.entries(entry.record).filter(([name]) => (
994
- !["schemaVersion", "id", "type", "title"].includes(name)
1095
+ !["id", "type", "title"].includes(name)
995
1096
  && !fields[name]?.content
996
1097
  && !narrativeNames.has(name)
997
1098
  ));
@@ -1016,7 +1117,7 @@ function renderDetail(main, type, id) {
1016
1117
  ? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
1017
1118
  : "";
1018
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 +
1019
- '<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>';
1020
1121
  main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
1021
1122
  main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
1022
1123
  seed: issueSeed("finding", entry.record),
@@ -1033,7 +1134,7 @@ function renderDetail(main, type, id) {
1033
1134
  try {
1034
1135
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
1035
1136
  if (!response.ok) return showError(await responseMessage(response));
1036
- state = await fetchJson("/api/state");
1137
+ applyMutationState(await response.json());
1037
1138
  location.hash = "#/resources/" + encodeURIComponent(type);
1038
1139
  } catch (error) {
1039
1140
  showError(error.message);
@@ -1041,6 +1142,32 @@ function renderDetail(main, type, id) {
1041
1142
  });
1042
1143
  }
1043
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
+
1044
1171
  function issueSeed(type, source) {
1045
1172
  const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
1046
1173
  if (type === "finding") {
@@ -1079,11 +1206,67 @@ function recordContentDefinition(type) {
1079
1206
  };
1080
1207
  }
1081
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
+
1082
1263
  function resourceConnections(entry) {
1264
+ const relatedPeopleOnly = entry.record.type === "person";
1083
1265
  const connections = new Map();
1084
1266
  const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
1085
1267
  const add = (connectedEntry, reason) => {
1086
1268
  if (!connectedEntry || connectedEntry.record.id === entry.record.id) return;
1269
+ if (relatedPeopleOnly && connectedEntry.record.type !== "person") return;
1087
1270
  const existing = connections.get(connectedEntry.record.id) || { entry: connectedEntry, reasons: new Set() };
1088
1271
  existing.reasons.add(reason);
1089
1272
  connections.set(connectedEntry.record.id, existing);
@@ -1113,7 +1296,7 @@ function resourceConnections(entry) {
1113
1296
  });
1114
1297
  if (!sorted.length) return "";
1115
1298
  const visible = sorted.slice(0, 14);
1116
- 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>';
1117
1300
  }
1118
1301
 
1119
1302
  function navigationResourceTypes() {
@@ -1132,6 +1315,7 @@ function renderOrganization(main) {
1132
1315
  }
1133
1316
 
1134
1317
  function renderRepository(main) {
1318
+ if (state.repository?.mode === "trunk") return renderTrunkRepository(main);
1135
1319
  const settings = rendererSettingsEntry();
1136
1320
  const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
1137
1321
  const onboardingButton = settings && !state.readOnly ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>' : "";
@@ -1177,12 +1361,41 @@ function renderRepository(main) {
1177
1361
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
1178
1362
  }
1179
1363
 
1364
+ function renderTrunkRepository(main) {
1365
+ const repository = state.repository;
1366
+ const settings = rendererSettingsEntry();
1367
+ const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
1368
+ const onboardingButton = settings && repository.writesAllowed
1369
+ ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>'
1370
+ : "";
1371
+ const retryButton = repository.retrySafe
1372
+ ? '<button class="button primary" type="button" data-git-action="retry-sync">Retry sync</button>'
1373
+ : "";
1374
+ const pending = repository.pendingCommitsFilegrcOnly === false
1375
+ ? empty("Ahead commits include files outside this FileGRC workspace. Reconcile them with Git.")
1376
+ : repository.pendingCommits?.length
1377
+ ? '<ul class="changes">' + repository.pendingCommits.map((commit) => '<li><code>' + esc(commit.shortCommit) + '</code> ' + esc(commit.subject) + '</li>').join("") + '</ul>'
1378
+ : empty("No FileGRC commits are waiting to be pushed.");
1379
+ const lastSync = repository.lastSuccessfulSynchronization
1380
+ ? formatLocalDateTime(repository.lastSuccessfulSynchronization)
1381
+ : "No successful sync recorded by this server";
1382
+ const override = repository.developmentOverride
1383
+ ? '<div class="repository-override"><span class="status-dot warn"></span><div><strong>Development write override active</strong><p>Browser writes stay local. FileGRC will not fetch, commit, or push while this server uses <code>--allow-non-authoritative-writes</code>.</p></div></div>'
1384
+ : "";
1385
+ const validationBody = state.validation.diagnostics.length
1386
+ ? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
1387
+ : empty("No validation problems.");
1388
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div>' + override + '<section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
1389
+ main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
1390
+ main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
1391
+ }
1392
+
1180
1393
  async function runRepositoryGitAction(action) {
1181
1394
  const buttons = [...document.querySelectorAll("[data-git-action]")];
1182
1395
  const disabled = buttons.map((button) => button.disabled);
1183
1396
  const active = buttons.find((button) => button.dataset.gitAction === action);
1184
1397
  const status = document.querySelector(".repository-sync-status");
1185
- const label = action === "pull" ? "Pulling…" : "Pushing…";
1398
+ const label = action === "pull" ? "Pulling…" : action === "retry-sync" ? "Syncing…" : "Pushing…";
1186
1399
  if (status) {
1187
1400
  status.textContent = "";
1188
1401
  status.classList.remove("error");
@@ -1193,17 +1406,19 @@ async function runRepositoryGitAction(action) {
1193
1406
  const response = await localFetch("/api/git/" + action, { method: "POST" });
1194
1407
  if (!response.ok) throw new Error(await responseMessage(response));
1195
1408
  const result = await response.json();
1196
- state = await fetchJson("/api/state");
1409
+ applyMutationState(result);
1197
1410
  render();
1198
1411
  const currentStatus = document.querySelector(".repository-sync-status");
1199
1412
  if (currentStatus) currentStatus.textContent = action === "pull"
1200
1413
  ? result.updated
1201
1414
  ? "Pulled " + result.upstream + " with rebase at " + result.shortCommit + "."
1202
1415
  : result.branch + " is current with " + result.upstream + "."
1203
- : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
1416
+ : action === "retry-sync"
1417
+ ? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
1418
+ : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
1204
1419
  } catch (cause) {
1205
1420
  buttons.forEach((button, index) => { button.disabled = disabled[index]; });
1206
- if (active) active.textContent = action === "pull" ? "Pull with rebase" : "Push";
1421
+ if (active) active.textContent = action === "pull" ? "Pull with rebase" : action === "retry-sync" ? "Retry sync" : "Push";
1207
1422
  if (status) {
1208
1423
  status.textContent = cause.message;
1209
1424
  status.classList.add("error");
@@ -1239,7 +1454,7 @@ function openCommitDialog() {
1239
1454
  });
1240
1455
  if (!response.ok) throw new Error(await responseMessage(response));
1241
1456
  const result = await response.json();
1242
- state = await fetchJson("/api/state");
1457
+ applyMutationState(result);
1243
1458
  dialog.close();
1244
1459
  render();
1245
1460
  const status = document.querySelector(".repository-sync-status");
@@ -1385,12 +1600,13 @@ function requestOnboarding() {
1385
1600
  onboardingDialog = null;
1386
1601
  onboardingDraft = null;
1387
1602
  onboardingBusy = false;
1603
+ onboardingPendingDraft = false;
1388
1604
  });
1389
1605
  renderOnboardingStep();
1390
1606
  }
1391
1607
 
1392
1608
  function initialOnboardingDraft() {
1393
- 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");
1394
1610
  const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
1395
1611
  return {
1396
1612
  systemId: systemEntry?.record.id || "",
@@ -1398,7 +1614,7 @@ function initialOnboardingDraft() {
1398
1614
  scope: systemEntry?.record.description || "",
1399
1615
  ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
1400
1616
  criticality: systemEntry?.record.criticality || "high",
1401
- dataClassification: systemEntry?.record.dataClassification || "Confidential",
1617
+ classificationId: systemEntry?.record.classificationId || defaultClassificationId(),
1402
1618
  internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
1403
1619
  programGoal: programGoalFromKind(state.workspace.assuranceGoal)
1404
1620
  };
@@ -1413,8 +1629,9 @@ function onboardingSteps() {
1413
1629
  points: [
1414
1630
  "Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
1415
1631
  "JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
1416
- "Repository pulls with rebase, then commits and pushes reviewed changes together.",
1417
- "Agents and terminal users run git pull --rebase, git commit, and git push directly.",
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.",
1633
+ "Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
1634
+ "Agents and terminal users continue to manage Git explicitly.",
1418
1635
  "The dashboard derives program status from the current repository state."
1419
1636
  ]
1420
1637
  };
@@ -1424,7 +1641,7 @@ function onboardingSteps() {
1424
1641
  title: "Follow the audit chain",
1425
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.",
1426
1643
  points: [
1427
- "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.",
1428
1645
  "Program operation includes current risk assessments and risks, which may add or change controls as conditions change.",
1429
1646
  "Evidence preparation means cataloging authoritative systems, documenting extraction, and testing captures before the candidate period.",
1430
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."
@@ -1445,7 +1662,7 @@ function onboardingSteps() {
1445
1662
  target: ".event-reminder-panel",
1446
1663
  kicker: "Triggered work",
1447
1664
  title: "Complete a checklist when key events occur",
1448
- 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.",
1449
1666
  points: [
1450
1667
  "The checklist stays open until every action is done and has the requested completion record or evidence.",
1451
1668
  "Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
@@ -1517,7 +1734,7 @@ function renderOnboardingStep() {
1517
1734
  ? onboardingSetupForm()
1518
1735
  : description + explanation + afterSections;
1519
1736
  const finalActions = onboardingStep === steps.length - 1
1520
- ? '<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>'
1521
1738
  : '<button class="button primary" type="button" data-onboarding="next">Next</button>';
1522
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>';
1523
1740
  onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
@@ -1554,29 +1771,27 @@ function renderOnboardingStep() {
1554
1771
  function onboardingSetupForm() {
1555
1772
  const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
1556
1773
  const classifications = Object.keys(state.workspace.classificationDefinitions || {});
1557
- if (onboardingDraft.dataClassification && !classifications.includes(onboardingDraft.dataClassification)) {
1558
- classifications.push(onboardingDraft.dataClassification);
1774
+ if (onboardingDraft.classificationId && !classifications.includes(onboardingDraft.classificationId)) {
1775
+ classifications.push(onboardingDraft.classificationId);
1559
1776
  }
1560
1777
  const currentSystem = onboardingDraft.systemId ? state.resources.find(({ record }) => record.id === onboardingDraft.systemId)?.record : null;
1561
1778
  const existing = [
1562
1779
  currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
1563
1780
  "Records a management program goal without creating an audit engagement."
1564
1781
  ].filter(Boolean).join(" ");
1565
- const gitStatus = state.git.available && !state.git.branch
1566
- ? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Detached HEAD detected</strong><small>Setup changes can be saved, but browser commits, pulls, and pushes are disabled until you check out a branch.</small></span></div>'
1567
- : state.git.available && state.git.remotes?.length
1568
- ? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Git repository and remote detected</strong><small>Setup changes will appear in the workspace diff. Browser commits push to the configured remote.</small></span></div>'
1569
- : state.git.available
1570
- ? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git remote needed</strong><small>Saving and local commits still work. Add a remote before the browser can push.</small></span></div>'
1571
- : '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Saving still works. Run <code>git init</code> at the workspace root before your first compliance commit.</small></span></div>';
1572
- 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. Saving writes JSON files but does not commit them. Complete the remaining Step 1 pages next.</p>';
1782
+ const gitStatus = state.repository?.mode === "trunk"
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>'
1784
+ : state.git.available && state.git.branch
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>'
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>';
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>';
1573
1788
  }
1574
1789
 
1575
1790
  function captureOnboardingForm() {
1576
1791
  const form = onboardingDialog?.querySelector("#onboarding-setup");
1577
1792
  if (!form) return;
1578
1793
  const data = new FormData(form);
1579
- for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "programGoal"]) {
1794
+ for (const name of ["serviceName", "scope", "ownerId", "criticality", "classificationId", "internetExposed", "programGoal"]) {
1580
1795
  onboardingDraft[name] = String(data.get(name) || "").trim();
1581
1796
  }
1582
1797
  }
@@ -1596,7 +1811,7 @@ async function saveOnboarding(draft = false) {
1596
1811
  boundary: onboardingDraft.scope,
1597
1812
  ownerId: onboardingDraft.ownerId,
1598
1813
  criticality: onboardingDraft.criticality,
1599
- dataClassification: onboardingDraft.dataClassification,
1814
+ classificationId: onboardingDraft.classificationId,
1600
1815
  internetExposed: onboardingDraft.internetExposed === "true",
1601
1816
  programGoal: onboardingDraft.programGoal,
1602
1817
  systemId: onboardingDraft.systemId,
@@ -1604,14 +1819,55 @@ async function saveOnboarding(draft = false) {
1604
1819
  })
1605
1820
  });
1606
1821
  if (!response.ok) throw new Error(await responseMessage(response));
1607
- 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
+ }
1608
1831
  closeOnboarding();
1609
1832
  history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
1610
1833
  render();
1611
1834
  } catch (error) {
1612
1835
  setOnboardingBusy(false);
1613
- const errorNode = onboardingDialog?.querySelector(".dialog-error");
1614
- 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);
1615
1871
  }
1616
1872
  }
1617
1873
 
@@ -1632,8 +1888,7 @@ async function cancelOnboarding() {
1632
1888
  async function persistOnboardingPreference(showOnboarding) {
1633
1889
  const entry = rendererSettingsEntry();
1634
1890
  if (!entry) throw new Error("Renderer settings are unavailable.");
1635
- await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry);
1636
- state = await fetchJson("/api/state");
1891
+ applyMutationState(await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry));
1637
1892
  }
1638
1893
 
1639
1894
  async function writeRendererSettingsResource(record, entry) {
@@ -1646,6 +1901,7 @@ async function writeRendererSettingsResource(record, entry) {
1646
1901
  body: JSON.stringify({ record, revision: entry?.revision })
1647
1902
  });
1648
1903
  if (!response.ok) throw new Error(await responseMessage(response));
1904
+ return response.json();
1649
1905
  }
1650
1906
 
1651
1907
  async function toggleStagePageCompletion(button) {
@@ -1655,25 +1911,31 @@ async function toggleStagePageCompletion(button) {
1655
1911
  const pageId = button.dataset.stagePageCompletion;
1656
1912
  if (button.dataset.complete === "true") {
1657
1913
  completed.delete(pageId);
1658
- (STAGE_PAGE_ID_ALIASES[pageId] || []).forEach((id) => completed.delete(id));
1659
1914
  } else {
1660
1915
  completed.add(pageId);
1661
1916
  }
1662
- await writeRendererSettingsResource({
1917
+ applyMutationState(await writeRendererSettingsResource({
1663
1918
  ...entry.record,
1664
1919
  completedStagePageIds: [...completed].sort()
1665
- }, entry);
1666
- state = await fetchJson("/api/state");
1920
+ }, entry));
1667
1921
  }
1668
1922
 
1669
1923
  function setOnboardingBusy(busy, label = "") {
1670
1924
  if (!onboardingDialog) return;
1925
+ clearTimeout(onboardingStillWorkingTimer);
1926
+ onboardingStillWorkingTimer = null;
1671
1927
  onboardingBusy = busy;
1672
1928
  onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
1673
1929
  const next = onboardingDialog.querySelector('[data-onboarding="next"]');
1674
1930
  if (next && label) next.textContent = label;
1675
1931
  const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
1676
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
+ }
1677
1939
  if (!busy) renderOnboardingStep();
1678
1940
  }
1679
1941
 
@@ -1766,6 +2028,8 @@ function clearOnboardingFocus() {
1766
2028
 
1767
2029
  function closeOnboarding() {
1768
2030
  if (!onboardingDialog) return;
2031
+ clearTimeout(onboardingStillWorkingTimer);
2032
+ onboardingStillWorkingTimer = null;
1769
2033
  onboardingBusy = false;
1770
2034
  clearOnboardingFocus();
1771
2035
  onboardingDialog.close();
@@ -1802,9 +2066,10 @@ function openEditor(type, entry = null, options = {}) {
1802
2066
  "title",
1803
2067
  ...required,
1804
2068
  ...(definition.listFields || []),
2069
+ ...(definition.formFields || []),
1805
2070
  ...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
1806
2071
  ...oneOf
1807
- ])].filter((name) => !["schemaVersion", "id", "type"].includes(name) && fields[name]);
2072
+ ])].filter((name) => !["id", "type"].includes(name) && fields[name]);
1808
2073
  const dialog = document.createElement("dialog");
1809
2074
  dialog.className = "editor";
1810
2075
  dialog.setAttribute("aria-labelledby", "resource-editor-title");
@@ -1813,7 +2078,10 @@ function openEditor(type, entry = null, options = {}) {
1813
2078
  ));
1814
2079
  const recordContent = recordContentDefinition(type);
1815
2080
  const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
1816
- 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>' +
1817
2085
  activeMarkdown.map((markdown) => {
1818
2086
  const generated = !entry?.content?.[markdown.name];
1819
2087
  const source = entry?.content?.[markdown.name]?.source
@@ -1826,7 +2094,7 @@ function openEditor(type, entry = null, options = {}) {
1826
2094
  : "";
1827
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>';
1828
2096
  }).join("") + renderRecordContentEditor(type, entry, options) +
1829
- '<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>';
1830
2098
  document.body.append(dialog);
1831
2099
  dialog.showModal();
1832
2100
  dialog.addEventListener("close", () => dialog.remove());
@@ -1857,6 +2125,7 @@ function openEditor(type, entry = null, options = {}) {
1857
2125
  }
1858
2126
  dialog.querySelector("form").addEventListener("submit", async (event) => {
1859
2127
  event.preventDefault();
2128
+ if (dialog.dataset.mutationBusy === "true") return;
1860
2129
  try {
1861
2130
  const advanced = dialog.dataset.jsonDirty === "true";
1862
2131
  if (!advanced && !dialog.querySelector("form").reportValidity()) return;
@@ -1887,6 +2156,7 @@ function openEditor(type, entry = null, options = {}) {
1887
2156
  ...activeMarkdown.map(({ name }) => [entry?.content?.[name]?.path, entry?.content?.[name]?.revision]),
1888
2157
  [recordContentItem?.path, recordContentItem?.revision]
1889
2158
  ].filter(([path, revision]) => path && revision));
2159
+ setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
1890
2160
  const response = await localFetch(url, {
1891
2161
  method: entry ? "PUT" : "POST",
1892
2162
  headers: { "content-type": "application/json" },
@@ -1899,18 +2169,29 @@ function openEditor(type, entry = null, options = {}) {
1899
2169
  })
1900
2170
  });
1901
2171
  if (!response.ok) throw new Error(await responseMessage(response));
1902
- state = await fetchJson("/api/state");
2172
+ applyMutationState(await response.json());
1903
2173
  dialog.close();
1904
2174
  location.hash = "#/resource/" + encodeURIComponent(updated.type) + "/" + encodeURIComponent(updated.id);
1905
2175
  render();
1906
2176
  } catch (error) {
2177
+ setMutationBusy(dialog, false, "", options.saveLabel || "Save file");
1907
2178
  dialog.querySelector(".dialog-error").textContent = error.message;
1908
2179
  }
1909
2180
  });
1910
2181
  }
1911
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
+
1912
2193
  function seedRecord(type, definition) {
1913
- 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: "" };
1914
2195
  const fields = { ...state.model.commonFields, ...definition.fields };
1915
2196
  for (const name of definition.required || []) {
1916
2197
  const field = fields[name];
@@ -1985,20 +2266,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
1985
2266
  : field.relation ? relationHelp(field)
1986
2267
  : "";
1987
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
+ }
1988
2273
  if (field.relation && field.type === "array") {
1989
2274
  const candidates = relationCandidates(field);
1990
2275
  control = candidates.length
1991
- ? '<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>'
1992
2277
  : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
1993
2278
  return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
1994
2279
  }
1995
2280
  if (field.relation) {
1996
2281
  const candidates = relationCandidates(field);
1997
2282
  control = candidates.length
1998
- ? '<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>'
1999
2284
  : required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
2000
2285
  return fieldWrap(name, "relation", label, requiredMark, control, help, required);
2001
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
+ }
2002
2292
  if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
2003
2293
  const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
2004
2294
  control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
@@ -2014,7 +2304,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
2014
2304
  }
2015
2305
  if (field.type === "array") {
2016
2306
  control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
2017
- 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);
2018
2311
  }
2019
2312
  if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
2020
2313
  control = '<textarea>' + esc(value ?? "") + '</textarea>';
@@ -2028,6 +2321,10 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
2028
2321
  return fieldWrap(name, field.type, label, requiredMark, control, help, required);
2029
2322
  }
2030
2323
 
2324
+ function evidenceSourceRoleOptions() {
2325
+ return [...new Set((state.model.evidenceSourceFamilies || []).flatMap(({ sourceKinds }) => sourceKinds || []))].sort();
2326
+ }
2327
+
2031
2328
  function fieldWrap(name, kind, label, requiredMark, control, help, required) {
2032
2329
  const labelId = "field-label-" + name;
2033
2330
  let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
@@ -2068,14 +2365,28 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups, markdownDefin
2068
2365
  };
2069
2366
  const refresh = () => {
2070
2367
  for (const [name, field] of Object.entries(fields)) {
2071
- if (!field.requiredWhen) continue;
2072
2368
  const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
2073
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
+ }
2074
2385
  const visible = !field.visibleWhen || conditionMatchesValues(field.visibleWhen, currentValue);
2075
2386
  const applicable = Object.entries(field.requiredWhen)
2076
2387
  .filter(([conditionName]) => conditionName !== "status")
2077
2388
  .every(([conditionName, expected]) => conditionValueMatches(currentValue(conditionName), expected));
2078
- group.hidden = !visible || !applicable;
2389
+ group.hidden = !visible || (!applicable && field.showWhenInactive !== true);
2079
2390
  const required = visible && applicable && conditionMatchesValues(field.requiredWhen, currentValue);
2080
2391
  refreshGroup(group, required);
2081
2392
  }
@@ -2133,6 +2444,10 @@ function readGuidedRecord(dialog, base, fields) {
2133
2444
  const record = structuredClone(base);
2134
2445
  for (const group of dialog.querySelectorAll("[data-field-group]")) {
2135
2446
  const name = group.dataset.fieldGroup;
2447
+ if (group.hidden) {
2448
+ delete record[name];
2449
+ continue;
2450
+ }
2136
2451
  const kind = group.dataset.kind;
2137
2452
  let value;
2138
2453
  if (kind === "relation-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
@@ -2157,7 +2472,11 @@ function relationCandidates(field) {
2157
2472
  }
2158
2473
 
2159
2474
  function relationHelp(field) {
2160
- 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);
2161
2480
  }
2162
2481
 
2163
2482
  function openContentEditor(entry, name) {
@@ -2166,18 +2485,21 @@ function openContentEditor(entry, name) {
2166
2485
  const dialog = document.createElement("dialog");
2167
2486
  dialog.className = "editor content-dialog";
2168
2487
  dialog.setAttribute("aria-labelledby", "content-editor-title");
2169
- 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>';
2170
2489
  document.body.append(dialog);
2171
2490
  dialog.showModal();
2172
2491
  dialog.addEventListener("close", () => dialog.remove());
2173
2492
  dialog.querySelector("#save-content").addEventListener("click", async () => {
2493
+ if (dialog.dataset.mutationBusy === "true") return;
2174
2494
  try {
2495
+ setMutationBusy(dialog, true, "Saving…", "Save Markdown");
2175
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 }) });
2176
2497
  if (!response.ok) throw new Error(await responseMessage(response));
2177
- state = await fetchJson("/api/state");
2498
+ applyMutationState(await response.json());
2178
2499
  dialog.close();
2179
2500
  render();
2180
2501
  } catch (error) {
2502
+ setMutationBusy(dialog, false, "", "Save Markdown");
2181
2503
  dialog.querySelector(".dialog-error").textContent = error.message;
2182
2504
  }
2183
2505
  });
@@ -2313,7 +2635,11 @@ function metric(label, value, note, tone) {
2313
2635
  }
2314
2636
  function countOverdue(entries) { const today = currentDate(); return entries.filter(({ record }) => dueDate(record) && dueDate(record) < today).length; }
2315
2637
  function dueDate(record) {
2316
- 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;
2317
2643
  if (explicit) return explicit;
2318
2644
  if (record.type !== "obligation" || record.status !== "active") return null;
2319
2645
  const recurrence = record.recurrence?.anchorDate
@@ -2353,6 +2679,9 @@ function currentPeopleForParties(ids = [], seen = new Set()) {
2353
2679
  if (party?.type === "team" && party.status === "active") {
2354
2680
  people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
2355
2681
  }
2682
+ if (party?.type === "appointment" && party.status === "active") {
2683
+ people.push(...currentPeopleForParties([party.holderId], seen));
2684
+ }
2356
2685
  }
2357
2686
  return [...new Set(people)];
2358
2687
  }
@@ -2420,6 +2749,19 @@ function fieldLabel(type, name) {
2420
2749
  function filterOptionLabel(value) { return state.resources.find(({ record }) => record.id === value)?.record.title || properCase(value); }
2421
2750
  function humanize(value) { return String(value).replace(/[-_]+/g, " ").replace(/Ids?$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (letter) => letter.toUpperCase()); }
2422
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
+ }
2423
2765
  function titleCase(value) {
2424
2766
  const words = String(value).split(/\s+/);
2425
2767
  return words.map((word, index) => {
@@ -2545,8 +2887,82 @@ ${nextCalendarOccurrence.toString()}
2545
2887
  ${formatCalendarDate.toString()}
2546
2888
  ${formatLocalDateTime.toString()}
2547
2889
  function empty(message) { return '<div class="empty">' + esc(message) + '</div>'; }
2548
- 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
+ }
2549
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
+
2550
2966
  function showError(message) {
2551
2967
  const dialog = document.createElement("dialog");
2552
2968
  dialog.className = "alert-dialog";
@@ -2562,10 +2978,35 @@ async function responseMessage(response) {
2562
2978
  try { return JSON.parse(source).error || source; } catch { return source; }
2563
2979
  }
2564
2980
  async function localFetch(url, options) {
2981
+ const method = String(options?.method || "GET").toUpperCase();
2982
+ const synchronizing = state?.repository?.mode === "trunk"
2983
+ && ["POST", "PUT", "DELETE"].includes(method)
2984
+ && url !== "/api/evidence-packet";
2985
+ const chip = synchronizing ? document.querySelector(".repo-chip") : null;
2986
+ const previousChip = chip?.innerHTML;
2987
+ let repositoryRefreshed = false;
2988
+ if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
2565
2989
  try {
2566
- return await fetch(url, options);
2990
+ const response = await fetch(url, options);
2991
+ if (synchronizing && !response.ok) {
2992
+ try {
2993
+ const stateResponse = await fetch("/api/state");
2994
+ if (stateResponse.ok) {
2995
+ state = await stateResponse.json();
2996
+ if (chip?.isConnected) {
2997
+ chip.innerHTML = '<span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span>' + esc(state.repository.label);
2998
+ repositoryRefreshed = true;
2999
+ }
3000
+ }
3001
+ } catch {
3002
+ // The original response contains the useful mutation error.
3003
+ }
3004
+ }
3005
+ return response;
2567
3006
  } catch {
2568
3007
  throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
3008
+ } finally {
3009
+ if (!repositoryRefreshed && chip?.isConnected && previousChip) chip.innerHTML = previousChip;
2569
3010
  }
2570
3011
  }
2571
3012
  async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
@@ -2585,8 +3026,9 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
2585
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}
2586
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)}
2587
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}
2588
- .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}
2589
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}
2590
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}
2591
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}
2592
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}
@@ -2598,18 +3040,21 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
2598
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}
2599
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}
2600
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}
2601
- .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)}.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}
2602
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}
2603
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}}
2604
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))}}
2605
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}}
2606
- @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}}
2607
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}}
2608
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}}
2609
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}}
2610
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}}
2611
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)}}
2612
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}
2613
3058
  body,button,input,select,textarea,dialog{color:var(--ink)}
2614
3059
  button,input,select,textarea{accent-color:var(--accent)}
2615
3060
  :focus-visible{outline:3px solid var(--focus);outline-offset:2px}