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