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/README.md +17 -7
- package/model/index.js +37 -3
- package/model/v1.json +89 -52
- package/model/v2.json +8022 -0
- package/package.json +1 -1
- package/src/agent.js +36 -8
- package/src/audit-preparation.js +63 -60
- package/src/cli.js +176 -113
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +115 -75
- package/src/files.js +230 -28
- package/src/git-name.js +16 -0
- package/src/git.js +702 -6
- package/src/index.js +9 -6
- package/src/model-docs.js +88 -7
- package/src/model-migration.js +1463 -0
- package/src/mutation.js +46 -1
- package/src/obligations.js +108 -84
- package/src/parties.js +17 -2
- package/src/program-path.js +31 -58
- package/src/program-readiness.js +142 -106
- package/src/resource-status.js +17 -0
- package/src/server.js +175 -36
- package/src/setup.js +27 -28
- package/src/state.js +93 -27
- package/src/timing.js +41 -0
- package/src/validate.js +611 -43
- package/src/web.js +586 -141
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/web.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
utcCalendarDate,
|
|
9
9
|
validCalendarRecurrence
|
|
10
10
|
} from "./recurrence.js";
|
|
11
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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?.
|
|
418
|
+
? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
|
|
358
419
|
: goal === "soc-2-type-1"
|
|
359
|
-
? Boolean(program?.target?.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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) && !
|
|
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.
|
|
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 || []),
|
|
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,
|
|
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",
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
634
|
-
|
|
733
|
+
artifactKind: "business-record",
|
|
734
|
+
artifactSubtype: item.activityType || "control-operation",
|
|
735
|
+
sourceKind: "authored-record",
|
|
736
|
+
sourceDescription: "Internal control operation",
|
|
635
737
|
collectedOn: date,
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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.
|
|
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?.
|
|
712
|
-
const end = selected?.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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?.
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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(
|
|
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
|
-
!["
|
|
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 & 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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
1417
|
-
"
|
|
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
|
|
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,
|
|
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.
|
|
1558
|
-
classifications.push(onboardingDraft.
|
|
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.
|
|
1566
|
-
? '<div class="onboarding-git-status warning"><span class="status-dot
|
|
1567
|
-
: state.git.available && state.git.
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
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) => !["
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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}
|