filegrc 0.1.0 → 0.3.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/LICENSE +1 -1
- package/README.md +24 -5
- package/model/v1.json +163 -169
- package/package.json +2 -2
- package/src/agent.js +4 -0
- package/src/audit-preparation.js +217 -139
- package/src/cli.js +364 -9
- package/src/evidence-packet.js +78 -25
- package/src/evidence-tests.js +69 -0
- package/src/git.js +2 -1
- package/src/index.js +12 -1
- package/src/model-docs.js +51 -6
- package/src/obligations.js +87 -11
- package/src/parties.js +46 -0
- package/src/paths.js +1 -1
- package/src/program-lifecycle.js +25 -0
- package/src/program-path.js +275 -0
- package/src/program-readiness.js +652 -0
- package/src/resource-markdown.js +11 -4
- package/src/server.js +10 -2
- package/src/setup.js +187 -0
- package/src/state.js +9 -2
- package/src/validate.js +57 -24
- package/src/web.js +827 -440
package/src/web.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
utcCalendarDate,
|
|
9
9
|
validCalendarRecurrence
|
|
10
10
|
} from "./recurrence.js";
|
|
11
|
+
import { POLICY_EVENT_NAMES, PROGRAM_PATH, RESOURCE_INSTRUCTIONS } from "./program-path.js";
|
|
11
12
|
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
12
13
|
|
|
13
14
|
export function renderIndex(state = null) {
|
|
@@ -20,7 +21,7 @@ export function renderIndex(state = null) {
|
|
|
20
21
|
<meta charset="utf-8">
|
|
21
22
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
22
23
|
<meta name="color-scheme" content="light dark">
|
|
23
|
-
<title>
|
|
24
|
+
<title>filegrc</title>
|
|
24
25
|
<link rel="icon" type="image/png" href="./favicon.png">
|
|
25
26
|
<link rel="stylesheet" href="./filegrc.css">
|
|
26
27
|
</head>
|
|
@@ -39,74 +40,24 @@ let state;
|
|
|
39
40
|
const LIST_PAGE_SIZE = 25;
|
|
40
41
|
const SEARCH_PAGE_SIZE = 25;
|
|
41
42
|
const NAV_GROUP_STORAGE_KEY = "filegrc.sidebar.groups.v3";
|
|
43
|
+
const NAV_SCROLL_STORAGE_KEY = "filegrc.sidebar.scroll.v1";
|
|
44
|
+
let navigationScrollTop = readNavigationScrollTop();
|
|
42
45
|
let latestPacketResult = null;
|
|
43
46
|
let latestPacketState = null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
title: "Criteria",
|
|
59
|
-
description: "What the auditor evaluates",
|
|
60
|
-
sections: [
|
|
61
|
-
{ id: "framework", title: "Framework", types: ["framework", "requirement"], nested: true, defaultOpen: true },
|
|
62
|
-
{ id: "service-description", title: "Supplements", types: ["commitment", "complementary-control"], nested: true, defaultOpen: false }
|
|
63
|
-
]
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: "policies",
|
|
67
|
-
number: "3",
|
|
68
|
-
title: "Policies",
|
|
69
|
-
description: "Rules the company adopts",
|
|
70
|
-
sections: [
|
|
71
|
-
{ id: "library", title: "Policy Library", types: ["policy", "document"], defaultOpen: true }
|
|
72
|
-
]
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
id: "controls",
|
|
76
|
-
number: "4",
|
|
77
|
-
title: "Controls",
|
|
78
|
-
description: "How the rules operate",
|
|
79
|
-
sections: [
|
|
80
|
-
{ id: "catalog", title: "Control Catalog", types: ["control"], defaultOpen: true },
|
|
81
|
-
{ id: "testing", title: "Testing", types: ["control-test"], defaultOpen: false }
|
|
82
|
-
]
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
id: "run",
|
|
86
|
-
number: "5",
|
|
87
|
-
title: "Operate Controls",
|
|
88
|
-
description: "Recurring and event work",
|
|
89
|
-
sections: [
|
|
90
|
-
{ id: "queue", title: "Work Queue", types: ["obligation", "obligation-event", "action-item"], utility: "obligation-board", nested: true, defaultOpen: true },
|
|
91
|
-
{ id: "governance-risk", title: "Governance and Risk", types: ["policy-review", "meeting", "risk-assessment", "risk", "exception"], nested: true, defaultOpen: false },
|
|
92
|
-
{ id: "access-training", title: "Access and Training", types: ["access-grant", "access-review", "service-account", "training", "attestation"], nested: true, defaultOpen: false },
|
|
93
|
-
{ id: "security", title: "Security Operations", types: ["vulnerability-scan", "vulnerability", "penetration-test", "incident"], nested: true, defaultOpen: false },
|
|
94
|
-
{ id: "resilience", title: "Resilience", types: ["backup-test", "exercise"], nested: true, defaultOpen: false },
|
|
95
|
-
{ id: "evidence-issues", title: "Evidence and Issues", types: ["evidence", "finding", "data-request"], nested: true, defaultOpen: false }
|
|
96
|
-
]
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
id: "audit",
|
|
100
|
-
number: "6",
|
|
101
|
-
title: "Audit",
|
|
102
|
-
description: "Firm, requests, and report",
|
|
103
|
-
sections: [
|
|
104
|
-
{ id: "engagement", title: "Engagement", types: ["audit", "audit-request"], defaultOpen: true },
|
|
105
|
-
{ id: "packet", title: "Evidence Delivery", types: ["audit-population"], utility: "audit-packet", nested: true, defaultOpen: true }
|
|
106
|
-
]
|
|
107
|
-
}
|
|
108
|
-
];
|
|
109
|
-
const ORGANIZATION_RESOURCE_TYPES = ["person", "team"];
|
|
47
|
+
let policyEventFeedback = null;
|
|
48
|
+
const SHARED_PROGRAM_STAGES = ${JSON.stringify(PROGRAM_PATH)};
|
|
49
|
+
const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
50
|
+
...stage,
|
|
51
|
+
number: String(stage.number)
|
|
52
|
+
}));
|
|
53
|
+
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
54
|
+
...RESOURCE_INSTRUCTIONS,
|
|
55
|
+
"utility:audit-packet": "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet."
|
|
56
|
+
})};
|
|
57
|
+
const POLICY_EVENT_NAMES = ${JSON.stringify(POLICY_EVENT_NAMES)};
|
|
58
|
+
const STAGE_PAGE_ID_ALIASES = {
|
|
59
|
+
"controls:complementary-control": ["scope:complementary-control"]
|
|
60
|
+
};
|
|
110
61
|
const OBLIGATION_COMPLETION_TYPES = {
|
|
111
62
|
"access-review": "access-review",
|
|
112
63
|
"backup-test": "backup-test",
|
|
@@ -126,6 +77,8 @@ const OBLIGATION_COMPLETION_TYPES = {
|
|
|
126
77
|
"vulnerability-scan": "vulnerability-scan"
|
|
127
78
|
};
|
|
128
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"]);
|
|
80
|
+
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
|
+
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"]);
|
|
129
82
|
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"]);
|
|
130
83
|
const navigationGroupState = readNavigationGroupState();
|
|
131
84
|
let onboardingDialog = null;
|
|
@@ -155,11 +108,16 @@ async function start() {
|
|
|
155
108
|
function render() {
|
|
156
109
|
resourceGuideCleanup?.();
|
|
157
110
|
resourceGuideCleanup = null;
|
|
111
|
+
const previousNavigation = root.querySelector(".sidebar-nav");
|
|
112
|
+
if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
|
|
158
113
|
const route = parseRoute();
|
|
159
114
|
const nav = buildNavigation(route);
|
|
160
115
|
root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header><main id="main"></main></div></div>';
|
|
116
|
+
const nextNavigation = root.querySelector(".sidebar-nav");
|
|
117
|
+
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
161
118
|
const main = root.querySelector("main");
|
|
162
119
|
if (route.name === "home") renderHome(main);
|
|
120
|
+
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
163
121
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
164
122
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
165
123
|
else if (route.name === "list") renderList(main, route.type, route.params);
|
|
@@ -179,6 +137,7 @@ function parseRoute() {
|
|
|
179
137
|
return { name: "missing" };
|
|
180
138
|
}
|
|
181
139
|
if (!parts.length) return { name: "home" };
|
|
140
|
+
if (parts.length === 2 && parts[0] === "stage" && parts[1]) return { name: "stage", stageId: parts[1], params: new URLSearchParams(query) };
|
|
182
141
|
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
183
142
|
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
184
143
|
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
@@ -191,6 +150,8 @@ function parseRoute() {
|
|
|
191
150
|
function buildNavigation(route) {
|
|
192
151
|
const currentStage = readinessStageForRoute(route);
|
|
193
152
|
const stages = READINESS_STAGES.map((stage) => {
|
|
153
|
+
const stagePageCurrent = (route.name === "stage" && route.stageId === stage.id)
|
|
154
|
+
|| (stage.id === "run" && route.name === "obligations");
|
|
194
155
|
const stageOpen = navigationGroupState[stage.id] ?? currentStage?.id === stage.id;
|
|
195
156
|
const sections = stage.sections.map((section) => {
|
|
196
157
|
const sectionKey = stage.id + ":" + section.id;
|
|
@@ -201,37 +162,43 @@ function buildNavigation(route) {
|
|
|
201
162
|
const resources = section.types
|
|
202
163
|
.map((type) => [type, state.model.resources[type]])
|
|
203
164
|
.filter(([, definition]) => definition);
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
165
|
+
const direct = stage.sections.length === 1 || sectionDestinations(section).length === 1;
|
|
166
|
+
const links = resources.map(([type, definition]) => {
|
|
167
|
+
return '<a class="' + (direct ? "nav-direct " : "") + (route.type === type ? "current" : "") + '" href="#/resources/' + encodeURIComponent(type) + '"><span>' + esc(titleCase(definition.pluralTitle)) + '</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
168
|
+
}).join("") + renderSidebarUtility(section.utility, route, direct);
|
|
169
|
+
if (direct) return links;
|
|
170
|
+
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>';
|
|
209
171
|
}).join("");
|
|
210
|
-
return '<section class="nav-group nav-stage ' + (stageOpen ? "open" : "") + '" data-group="' + esc(stage.id) + '"><
|
|
172
|
+
return '<section class="nav-group nav-stage ' + (stageOpen ? "open" : "") + '" data-group="' + esc(stage.id) + '"><div class="nav-heading-row"><a class="nav-heading ' + (stagePageCurrent ? "current" : "") + '" href="#/stage/' + encodeURIComponent(stage.id) + '"><span class="nav-stage-number">' + esc(stage.number) + '</span><span class="nav-stage-copy"><strong>' + esc(stage.title) + '</strong><small>' + esc(stage.description) + '</small></span></a><button class="nav-toggle nav-stage-toggle" type="button" aria-label="' + (stageOpen ? "Collapse " : "Expand ") + esc(stage.title) + '" aria-expanded="' + stageOpen + '" aria-controls="nav-group-' + esc(stage.id) + '"><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><div class="nav-items" id="nav-group-' + esc(stage.id) + '">' + sections + '</div></section>';
|
|
211
173
|
}).join("");
|
|
212
|
-
const organizationCurrent = route.name === "organization" || route.name === "repository" || ["workspace", "renderer-settings"
|
|
174
|
+
const organizationCurrent = route.name === "organization" || route.name === "repository" || ["workspace", "renderer-settings"].includes(route.type);
|
|
213
175
|
const organizationName = state.workspace.organizationName || "Organization";
|
|
214
176
|
const initial = organizationName.trim().charAt(0).toUpperCase() || "O";
|
|
215
|
-
return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>
|
|
177
|
+
return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>filegrc</strong><small>SOC 2 workspace</small></span></a><nav class="sidebar-nav">' + stages + '</nav><div class="sidebar-footer"><a class="organization-nav ' + (organizationCurrent ? "current" : "") + '" href="#/organization"><span class="organization-mark">' + esc(initial) + '</span><span><strong>' + esc(organizationName) + '</strong><small>Organization</small></span><span class="organization-arrow">›</span></a></div></aside><button class="nav-scrim" type="button" aria-label="Close navigation"></button>';
|
|
216
178
|
}
|
|
217
179
|
|
|
218
180
|
function readinessStageForRoute(route) {
|
|
219
|
-
return READINESS_STAGES.find((stage) => stage.
|
|
220
|
-
|
|
221
|
-
|
|
181
|
+
if (route.name === "stage") return READINESS_STAGES.find((stage) => stage.id === route.stageId);
|
|
182
|
+
return READINESS_STAGES.find((stage) => (
|
|
183
|
+
(stage.supportingResourceTypes || []).includes(route.type)
|
|
184
|
+
|| stage.sections.some((section) => section.types.includes(route.type)
|
|
185
|
+
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
186
|
+
|| (section.utility === "audit-packet" && route.name === "audit-packet"))
|
|
187
|
+
));
|
|
222
188
|
}
|
|
223
189
|
|
|
224
190
|
function readinessStageForType(type) {
|
|
225
|
-
return READINESS_STAGES.find((stage) =>
|
|
191
|
+
return READINESS_STAGES.find((stage) => (
|
|
192
|
+
(stage.supportingResourceTypes || []).includes(type)
|
|
193
|
+
|| stage.sections.some((section) => section.types.includes(type))
|
|
194
|
+
));
|
|
226
195
|
}
|
|
227
196
|
|
|
228
197
|
function renderSidebarUtility(utility, route, direct = false) {
|
|
229
198
|
const directClass = direct ? "nav-direct " : "";
|
|
230
|
-
if (utility === "obligation-board")
|
|
231
|
-
return '<a class="' + directClass + (route.name === "obligations" ? "current" : "") + '" href="#/obligations"><span>Obligation Board</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
232
|
-
}
|
|
199
|
+
if (utility === "obligation-board") return "";
|
|
233
200
|
if (utility === "audit-packet") {
|
|
234
|
-
return '<a class="' + directClass + 'audit-packet-link ' + (route.name === "audit-packet" ? "current" : "") + '" href="#/audit-packet"><span>Audit
|
|
201
|
+
return '<a class="' + directClass + 'audit-packet-link ' + (route.name === "audit-packet" ? "current" : "") + '" href="#/audit-packet"><span>Audit Evidence & Packet</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
235
202
|
}
|
|
236
203
|
return "";
|
|
237
204
|
}
|
|
@@ -239,78 +206,300 @@ function renderSidebarUtility(utility, route, direct = false) {
|
|
|
239
206
|
function topbar(route) {
|
|
240
207
|
const title = route.name === "home"
|
|
241
208
|
? "Program Overview"
|
|
209
|
+
: route.name === "stage"
|
|
210
|
+
? READINESS_STAGES.find((stage) => stage.id === route.stageId)?.title || "Program"
|
|
242
211
|
: route.name === "organization"
|
|
243
212
|
? "Organization"
|
|
244
213
|
: route.name === "repository"
|
|
245
214
|
? "Repository"
|
|
246
215
|
: route.name === "obligations"
|
|
247
|
-
? "
|
|
216
|
+
? "Work Queue"
|
|
248
217
|
: route.name === "audit-packet"
|
|
249
218
|
? "Audit Readiness"
|
|
250
|
-
: state.model.resources[route.type]?.pluralTitle || "
|
|
219
|
+
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
251
220
|
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + (state.git.clean ? "good" : "warn") + '"></span>' + esc(state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</a></div>';
|
|
252
221
|
}
|
|
253
222
|
|
|
254
223
|
function renderHome(main) {
|
|
255
224
|
const activeAudit = resourcesOfType("audit").find((item) => !["complete", "closed", "cancelled"].includes(item.record.status));
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
225
|
+
const program = state.programReadiness;
|
|
226
|
+
const activeFirm = activeAudit && (activeAudit.record.auditorVendorId || (activeAudit.record.auditor && Object.keys(activeAudit.record.auditor).length));
|
|
227
|
+
const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
|
|
228
|
+
const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
|
|
229
|
+
const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
|
|
230
|
+
const previewObligations = distinctObligationPreviews(openObligations, 3);
|
|
231
|
+
const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
|
|
232
|
+
const setupBanner = setupPending ? initialSetupBanner() : "";
|
|
233
|
+
const auditPanel = program.evidenceReady
|
|
234
|
+
? '<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Optional next phase</p><h3>' + esc(activeFirm ? titleCase(activeAudit.record.title) : "Target: " + program.target.label) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Engagements</a>') + '</div>' +
|
|
235
|
+
(activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
|
|
236
|
+
: "";
|
|
237
|
+
main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
|
|
238
|
+
'<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
|
|
239
|
+
'<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(state.obligations.triggers.slice(0, 4)) + '</section>' +
|
|
240
|
+
auditPanel + '</div></div>';
|
|
241
|
+
main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function initialSetupBanner() {
|
|
245
|
+
const system = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired")?.record;
|
|
246
|
+
if (!system) {
|
|
247
|
+
return '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
248
|
+
}
|
|
249
|
+
const goal = programGoalFromKind(state.workspace.assuranceGoal);
|
|
250
|
+
const goalLabels = {
|
|
251
|
+
readiness: "Program Readiness",
|
|
252
|
+
"type-1": "SOC 2 Type 1",
|
|
253
|
+
"type-2": "SOC 2 Type 2"
|
|
254
|
+
};
|
|
255
|
+
const goalStep = goal === "none"
|
|
256
|
+
? "Choose the program goal."
|
|
257
|
+
: "Confirm the saved program goal: " + goalLabels[goal] + ".";
|
|
258
|
+
const completion = system.status === "planned"
|
|
259
|
+
? "Complete setup to activate the planned service and continue to Step 1."
|
|
260
|
+
: "Complete setup to close onboarding and continue to Step 1.";
|
|
261
|
+
return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review and complete initial setup</h3><p>' + esc(system.title) + ' already has a saved service boundary.</p></div><ol><li>Review the saved service boundary.</li><li>' + esc(goalStep) + '</li><li>' + esc(completion) + '</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
function readinessOverview() {
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
265
|
+
const progress = programPathProgress();
|
|
266
|
+
const nextHref = nextProgramStageHref();
|
|
267
|
+
const programStage = (id, body, href) => {
|
|
268
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
|
|
269
|
+
const current = stageProgress(stage);
|
|
270
|
+
const remaining = current.total - current.complete;
|
|
271
|
+
const status = !remaining ? "Review complete" : current.complete ? remaining + " pages to review" : "Review not marked";
|
|
272
|
+
return [stage.title, body, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
|
|
273
|
+
};
|
|
272
274
|
const stages = [
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
275
|
+
programStage("scope", "Confirm program ownership, criteria, and commitments, then describe the service, supporting systems, and dependencies.", "#/stage/scope"),
|
|
276
|
+
programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
|
|
277
|
+
programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
|
|
278
|
+
programStage("evidence", "For each control family, finish the source-system instructions and verify a real test export or capture.", "#/stage/evidence"),
|
|
279
|
+
programStage("run", "Begin the candidate period, maintain risk assessments, work the filegrc queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
|
|
280
|
+
programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
|
|
279
281
|
];
|
|
280
|
-
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>
|
|
282
|
+
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Page Review Progress</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program pages marked reviewed") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function nextProgramStageHref() {
|
|
286
|
+
const nextStage = READINESS_STAGES.find((stage) => {
|
|
287
|
+
const progress = stageProgress(stage);
|
|
288
|
+
return progress.complete < progress.total;
|
|
289
|
+
}) || READINESS_STAGES.at(-1);
|
|
290
|
+
if (!nextStage) return "#/stage/scope";
|
|
291
|
+
return "#/stage/" + encodeURIComponent(nextStage.id);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
295
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === stageId);
|
|
296
|
+
if (!stage) return renderNotFound(main);
|
|
297
|
+
if (stage.id === "run") return renderObligations(main, params);
|
|
298
|
+
const progress = stageProgress(stage);
|
|
299
|
+
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
300
|
+
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
|
|
301
|
+
renderStagePageIndex(stage) + '</div>';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderStagePageIndex(stage) {
|
|
305
|
+
const destinations = stagePageDestinations(stage);
|
|
306
|
+
const cards = destinations.map((destination, index) => stagePageCard(stage, destination, index)).join("");
|
|
307
|
+
return '<section class="stage-pages" aria-label="Step ' + esc(stage.number) + ' pages"><div class="stage-page-grid">' + cards + '</div></section>';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stagePageDestinations(stage) {
|
|
311
|
+
return stage.sections.flatMap((section) => sectionDestinations(section)
|
|
312
|
+
.filter((destination) => destination.utility !== "obligation-board")
|
|
313
|
+
.map((destination) => ({ ...destination, section })));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function stagePageId(stage, destination) {
|
|
317
|
+
return stage.id + ":" + (destination.type || "utility:" + destination.utility);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function stagePageComplete(pageId) {
|
|
321
|
+
const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
|
|
322
|
+
return [pageId, ...(STAGE_PAGE_ID_ALIASES[pageId] || [])].some((id) => completedPageIds.includes(id));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stagePageSummary(destination) {
|
|
326
|
+
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
327
|
+
const section = destination.section || (destination.type
|
|
328
|
+
? readinessStageForType(destination.type)?.sections.find((candidate) => candidate.types.includes(destination.type))
|
|
329
|
+
: null);
|
|
330
|
+
return STAGE_PAGE_SUMMARIES[summaryKey] || section?.description || "";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function stagePageCard(stage, destination, index) {
|
|
334
|
+
const details = destination.type ? resourceRollup(destination.type) : utilityRollup(destination.utility);
|
|
335
|
+
const summary = stagePageSummary(destination);
|
|
336
|
+
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
337
|
+
const pageId = stagePageId(stage, destination);
|
|
338
|
+
const complete = stagePageComplete(pageId);
|
|
339
|
+
const completionControl = state.readOnly
|
|
340
|
+
? '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + (complete ? "Complete" : "Not marked complete") + '</span>'
|
|
341
|
+
: '<button class="button stage-page-completion ' + (complete ? "complete" : "") + '" type="button" data-stage-page-completion="' + esc(pageId) + '" data-complete="' + complete + '">' + (complete ? "Mark incomplete" : "Mark complete") + '</button>';
|
|
342
|
+
return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><a class="stage-page-card-link" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"></a><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><span class="stage-page-rollup"><strong>' + esc(details.value) + '</strong><small>' + esc(details.label) + '</small></span></div><p>' + esc(summary) + '</p><div class="stage-page-card-foot"><span class="stage-page-open" aria-hidden="true">Open ›</span>' + completionControl + '</div></article>';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function stageProgress(stage) {
|
|
346
|
+
if (stage.id === "run") return operationProgress();
|
|
347
|
+
const pages = stagePageDestinations(stage);
|
|
348
|
+
const complete = pages.filter((destination) => stagePageComplete(stagePageId(stage, destination))).length;
|
|
349
|
+
return progressFromCounts(complete, pages.length, "page");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function operationProgress() {
|
|
353
|
+
const program = state.programReadiness;
|
|
354
|
+
const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
|
|
355
|
+
const asOf = program?.asOf || currentDate();
|
|
356
|
+
const candidateStarted = goal === "soc-2-type-2"
|
|
357
|
+
? Boolean(program?.target?.candidatePeriodStart && program.target.candidatePeriodStart <= asOf)
|
|
358
|
+
: goal === "soc-2-type-1"
|
|
359
|
+
? Boolean(program?.target?.candidateTypeOneAsOf)
|
|
360
|
+
: Boolean(program?.evidenceReady);
|
|
361
|
+
const overdue = state.obligations.counts.overdue || 0;
|
|
362
|
+
const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0);
|
|
363
|
+
if (complete) {
|
|
364
|
+
return {
|
|
365
|
+
percent: 100,
|
|
366
|
+
complete: 1,
|
|
367
|
+
total: 1,
|
|
368
|
+
status: "Operating",
|
|
369
|
+
tone: "good",
|
|
370
|
+
detail: "Evidence collection is running and the Work Queue has no overdue work."
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (overdue) {
|
|
374
|
+
return {
|
|
375
|
+
percent: 0,
|
|
376
|
+
complete: 0,
|
|
377
|
+
total: 1,
|
|
378
|
+
status: "Needs attention",
|
|
379
|
+
tone: "bad",
|
|
380
|
+
detail: overdue + " overdue Work Queue " + pluralize("item", overdue) + " must be resolved."
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (program?.evidenceReady && goal === "soc-2-type-2" && !candidateStarted) {
|
|
384
|
+
return {
|
|
385
|
+
percent: 0,
|
|
386
|
+
complete: 0,
|
|
387
|
+
total: 1,
|
|
388
|
+
status: "Ready to start",
|
|
389
|
+
tone: "warn",
|
|
390
|
+
detail: "Record the management candidate period start when evidence collection begins."
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
percent: 0,
|
|
395
|
+
complete: 0,
|
|
396
|
+
total: 1,
|
|
397
|
+
status: "Not started",
|
|
398
|
+
tone: "warn",
|
|
399
|
+
detail: "Finish the Evidence Ready work before starting program operation."
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function programPathProgress() {
|
|
404
|
+
const progress = READINESS_STAGES.map((stage) => stageProgress(stage));
|
|
405
|
+
return progressFromCounts(
|
|
406
|
+
progress.reduce((sum, current) => sum + current.complete, 0),
|
|
407
|
+
progress.reduce((sum, current) => sum + current.total, 0),
|
|
408
|
+
"program milestone"
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function progressFromCounts(complete, total, noun) {
|
|
413
|
+
if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
|
|
414
|
+
const percent = Math.round((complete / total) * 100);
|
|
415
|
+
const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked reviewed. This tracks page review, while readiness checks the records themselves.";
|
|
416
|
+
if (complete === total) return { percent: 100, complete, total, status: "Review complete", tone: "good", detail };
|
|
417
|
+
if (!complete) return { percent: 0, complete, total, status: "No pages marked", tone: "warn", detail };
|
|
418
|
+
return { percent, complete, total, status: "Review in progress", tone: "warn", detail };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stageProgressCard(progress) {
|
|
422
|
+
return '<div class="stage-progress-card"><div><span class="badge ' + esc(progress.tone) + '">' + esc(progress.status) + '</span><strong>' + progress.percent + '%</strong></div><div class="progress"><span style="width:' + progress.percent + '%"></span></div><p>' + esc(progress.detail) + '</p></div>';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function sectionDestinations(section) {
|
|
426
|
+
const destinations = [];
|
|
427
|
+
for (const type of section.types) {
|
|
428
|
+
const definition = state.model.resources[type];
|
|
429
|
+
if (!definition) continue;
|
|
430
|
+
destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
|
|
431
|
+
}
|
|
432
|
+
if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
|
|
433
|
+
if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc and External Evidence, prepare fieldwork, and build the indexed packet." });
|
|
434
|
+
return destinations;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function resourceRollup(type) {
|
|
438
|
+
const records = resourcesOfType(type).map(({ record }) => record);
|
|
439
|
+
if (!records.length) return { value: "0", label: "No records yet" };
|
|
440
|
+
const statuses = new Map();
|
|
441
|
+
records.forEach((record) => {
|
|
442
|
+
if (record.status) statuses.set(record.status, (statuses.get(record.status) || 0) + 1);
|
|
443
|
+
});
|
|
444
|
+
const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
|
|
445
|
+
return { value: String(records.length), label: statusText || pluralize("record", records.length) };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function utilityRollup(utility) {
|
|
449
|
+
if (utility === "obligation-board") {
|
|
450
|
+
const open = state.obligations.items.filter((item) => item.status !== "complete");
|
|
451
|
+
return { value: String(open.length), label: open.length ? "Open work items" : "No work due" };
|
|
452
|
+
}
|
|
453
|
+
if (utility === "audit-packet") {
|
|
454
|
+
const audits = resourcesOfType("audit");
|
|
455
|
+
return { value: String(audits.length), label: audits.length ? pluralize("engagement", audits.length) : "No engagement yet" };
|
|
456
|
+
}
|
|
457
|
+
return { value: "0", label: "Not started" };
|
|
281
458
|
}
|
|
282
459
|
|
|
283
460
|
function auditEngagementPrompt(audit = null) {
|
|
284
461
|
const hasAuditor = audit?.auditorVendorId || (audit?.auditor && Object.keys(audit.auditor).length);
|
|
285
462
|
if (hasAuditor) return "";
|
|
286
|
-
const heading = audit ? "
|
|
287
|
-
return '<div class="audit-engagement"><div><strong>' + heading + '</strong><p>
|
|
463
|
+
const heading = audit ? "CPA Firm Not Recorded" : "Optional: Engage a CPA Firm Early";
|
|
464
|
+
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>';
|
|
288
465
|
}
|
|
289
466
|
|
|
290
467
|
function renderObligations(main, params = new URLSearchParams()) {
|
|
468
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
|
|
291
469
|
const plan = state.obligations;
|
|
470
|
+
const controls = resourcesOfType("control");
|
|
471
|
+
const linkedControlIds = new Set(resourcesOfType("obligation").flatMap(({ record }) => record.controlIds || []));
|
|
472
|
+
const scheduledControls = controls.filter(({ record }) => linkedControlIds.has(record.id)).length;
|
|
473
|
+
const assignedFollowUp = plan.standaloneItems.length;
|
|
292
474
|
const visibleCardLimit = 6;
|
|
293
|
-
const sections = ["upcoming", "due", "overdue"].map((status) => {
|
|
475
|
+
const sections = ["proposed", "upcoming", "due", "overdue"].map((status) => {
|
|
294
476
|
const items = plan.items.filter((item) => item.status === status);
|
|
295
477
|
const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
296
478
|
const more = items.length > visibleCardLimit
|
|
297
479
|
? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - visibleCardLimit) + ' more</button>'
|
|
298
480
|
: "";
|
|
299
|
-
return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(status) + '</span><strong>' + items.length + '</strong></div><div class="obligation-cards">' + (items.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
|
|
481
|
+
return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' + items.length + '</strong></div><div class="obligation-cards">' + (items.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
|
|
300
482
|
}).join("");
|
|
301
|
-
const triggers = plan.triggers.map((trigger
|
|
302
|
-
const
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
main.innerHTML = '<div class="page obligation-board-page"><
|
|
483
|
+
const triggers = plan.triggers.map((trigger, index) => policyEventTrigger(trigger, index)).join("");
|
|
484
|
+
const feedback = policyEventFeedback
|
|
485
|
+
? '<section class="policy-event-feedback" role="status" aria-live="polite"><span class="status-dot good"></span><div><strong>Work added to the Work Queue</strong><p>' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '</p></div><button class="button" type="button" data-view-added-work>View Work Queue</button><button class="icon-button" type="button" data-dismiss-policy-event-feedback aria-label="Dismiss confirmation">×</button></section>'
|
|
486
|
+
: "";
|
|
487
|
+
main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
488
|
+
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(stageProgress(stage)) + '</section>' +
|
|
489
|
+
feedback +
|
|
490
|
+
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
|
|
491
|
+
'<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>' +
|
|
306
492
|
'<div class="obligation-board">' + sections + '</div>' +
|
|
307
|
-
'
|
|
308
|
-
'<section class="workflow-section"><div class="section-head"><div><p class="kicker">Event execution</p><h2>Active and Recent Workflows</h2><p>Link the requested completion records and evidence on each action item before marking it done.</p></div></div><div class="event-run-list">' + (runs.length ? runs.map(eventRunCard).join("") : empty("No policy events have been started.")) + '</div></section></div>';
|
|
493
|
+
'</section></div>';
|
|
309
494
|
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
310
495
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
311
496
|
if (trigger) openObligationEventDialog(trigger);
|
|
312
497
|
}));
|
|
313
|
-
main.querySelector("[data-
|
|
498
|
+
main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
499
|
+
main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
|
|
500
|
+
policyEventFeedback = null;
|
|
501
|
+
event.currentTarget.closest(".policy-event-feedback")?.remove();
|
|
502
|
+
});
|
|
314
503
|
main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
|
|
315
504
|
const column = button.closest("[data-obligation-column]");
|
|
316
505
|
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
@@ -323,6 +512,9 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
323
512
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
324
513
|
if (item) openObligationCompletion(item);
|
|
325
514
|
}));
|
|
515
|
+
main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
516
|
+
description: "Create a task only when follow-up from another record needs its own assignee, deadline, and completion proof. Point it to that source record; it will remain in Work Queue until done or canceled."
|
|
517
|
+
}));
|
|
326
518
|
const requestedEvent = params.get("event");
|
|
327
519
|
if (requestedEvent || params.get("section") === "events") {
|
|
328
520
|
queueMicrotask(() => {
|
|
@@ -332,28 +524,50 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
332
524
|
}
|
|
333
525
|
}
|
|
334
526
|
|
|
527
|
+
function policyEventTrigger(trigger, index) {
|
|
528
|
+
const tooltipId = "policy-event-tooltip-" + index;
|
|
529
|
+
const proposed = trigger.programStatus === "proposed";
|
|
530
|
+
const unavailable = state.readOnly || proposed;
|
|
531
|
+
const availability = proposed
|
|
532
|
+
? "Available after its governing policies are effective, at least one linked control is implemented, and every task has a current owner."
|
|
533
|
+
: state.readOnly
|
|
534
|
+
? "Open this workspace in writable mode to trigger the workflow."
|
|
535
|
+
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
536
|
+
return '<article class="policy-event-row"><div class="policy-event-name"><div class="policy-event-title"><strong>' + esc(policyEventName(trigger.eventType)) + '</strong><span class="policy-event-guide"><button class="guide-trigger policy-event-guide-trigger" type="button" aria-label="Show ' + esc(policyEventName(trigger.eventType)) + ' workflow steps" aria-describedby="' + tooltipId + '"><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><div class="policy-event-tooltip" id="' + tooltipId + '" role="tooltip"><strong>' + esc(proposed ? "Proposed workflow" : "Adds " + trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " to the Work Queue") + '</strong><ol>' + trigger.steps.map((step) => '<li><span>' + esc(step.title) + '</span><small>' + esc(eventStepSummary(step)) + '</small></li>').join("") + '</ol></div></span></div><small>' + esc(availability) + '</small></div><button class="button primary" type="button" data-start-event="' + esc(trigger.eventType) + '"' + (unavailable ? " disabled" : "") + '>Trigger Work</button></article>';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function policyEventName(eventType) {
|
|
540
|
+
return POLICY_EVENT_NAMES[eventType] || titleCase(humanize(eventType));
|
|
541
|
+
}
|
|
542
|
+
|
|
335
543
|
function obligationCard(item, collapsed = false) {
|
|
336
544
|
const type = item.actionItemId ? "action-item" : "obligation";
|
|
337
545
|
const id = item.actionItemId || item.obligationId;
|
|
338
546
|
const completion = !item.actionItemId ? obligationCompletionPlan(item) : null;
|
|
339
|
-
const
|
|
547
|
+
const canAct = completion?.blocked === "Assign current owner"
|
|
548
|
+
|| !["upcoming", "proposed"].includes(item.status);
|
|
549
|
+
const action = !state.readOnly && canAct && completion
|
|
340
550
|
? completion.blocked
|
|
341
551
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
342
552
|
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
343
553
|
: "";
|
|
344
|
-
|
|
554
|
+
const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring");
|
|
555
|
+
return '<article class="obligation-card status-' + esc(item.status) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(kind) + '</span><strong>' + esc(timingText(item)) + '</strong></div><h3><a href="#/resource/' + type + '/' + encodeURIComponent(id) + '">' + esc(titleCase(item.title)) + '</a></h3><p>' + esc(windowText(item)) + '</p><div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
|
|
345
556
|
}
|
|
346
557
|
|
|
347
558
|
function obligationCompletionPlan(item) {
|
|
348
559
|
const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
|
|
560
|
+
if (!currentPeopleForParties(item.ownerIds || []).length) {
|
|
561
|
+
return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
|
|
562
|
+
}
|
|
349
563
|
if (["access-review", "backup-test"].includes(type) && !resourcesOfType("system").some(({ record }) => record.inScope && record.status !== "retired")) {
|
|
350
564
|
return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
|
|
351
565
|
}
|
|
352
566
|
if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
|
|
353
567
|
return { type, blocked: "Add vendor first", href: "#/resources/vendor?new=1" };
|
|
354
568
|
}
|
|
355
|
-
if (type === "meeting" && !
|
|
356
|
-
return { type, blocked: "
|
|
569
|
+
if (type === "meeting" && !completionTeam(item)) {
|
|
570
|
+
return { type, blocked: "Finish team and chair", href: "#/resources/team" };
|
|
357
571
|
}
|
|
358
572
|
return { type };
|
|
359
573
|
}
|
|
@@ -377,15 +591,14 @@ function openObligationCompletion(item) {
|
|
|
377
591
|
function obligationCompletionSeed(type, item, obligation) {
|
|
378
592
|
const date = currentDate();
|
|
379
593
|
const timestamp = new Date().toISOString();
|
|
380
|
-
const
|
|
381
|
-
const ownerIds = (item.ownerIds || []).filter((id) => people.includes(id));
|
|
382
|
-
const responsiblePeople = ownerIds.length ? ownerIds : people.slice(0, 1);
|
|
594
|
+
const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
|
|
383
595
|
const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired").map(({ record }) => record.id);
|
|
384
596
|
const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
|
|
385
597
|
const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
|
|
386
598
|
const common = { title };
|
|
387
599
|
if (type === "meeting") {
|
|
388
|
-
|
|
600
|
+
const team = completionTeam(item);
|
|
601
|
+
return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledOn: date };
|
|
389
602
|
}
|
|
390
603
|
if (type === "policy-review") {
|
|
391
604
|
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", changesRequired: false, periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
|
|
@@ -428,12 +641,6 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
428
641
|
};
|
|
429
642
|
}
|
|
430
643
|
|
|
431
|
-
function eventRunCard(run) {
|
|
432
|
-
const percentage = run.actions.length ? Math.round((run.completeCount / run.actions.length) * 100) : 0;
|
|
433
|
-
const occurred = run.occurredAt ? formatLocalDateTime(run.occurredAt) : formatCalendarDate(run.occurredOn);
|
|
434
|
-
return '<article class="event-run"><div class="event-run-head"><div><span class="badge status-' + esc(run.status) + '">' + esc(run.status) + '</span><h3><a href="#/resource/obligation-event/' + encodeURIComponent(run.id) + '">' + esc(run.title) + '</a></h3><small>' + esc(occurred) + ' · ' + run.completeCount + ' of ' + run.actions.length + ' complete</small></div><strong>' + percentage + '%</strong></div><div class="progress"><span style="width:' + percentage + '%"></span></div><div class="event-actions">' + run.actions.map((action) => '<a href="#/resource/action-item/' + encodeURIComponent(action.actionItemId) + '"><span class="status-dot ' + (action.status === "complete" ? "good" : action.status === "overdue" ? "bad" : "warn") + '"></span><span><strong>' + esc(action.title) + '</strong><small>' + esc(action.status === "complete" ? "Complete" : windowText(action) + " · " + timingText(action)) + '</small></span></a>').join("") + '</div></article>';
|
|
435
|
-
}
|
|
436
|
-
|
|
437
644
|
function openObligationEventDialog(trigger) {
|
|
438
645
|
const subjectType = trigger.eventType.startsWith("person") || trigger.eventType.includes("person-") ? "person"
|
|
439
646
|
: trigger.eventType.startsWith("vendor") ? "vendor"
|
|
@@ -448,9 +655,9 @@ function openObligationEventDialog(trigger) {
|
|
|
448
655
|
const dialog = document.createElement("dialog");
|
|
449
656
|
dialog.className = "commit-dialog event-dialog";
|
|
450
657
|
dialog.setAttribute("aria-labelledby", "event-dialog-title");
|
|
451
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Policy event</p><h2 id="event-dialog-title">' + esc(
|
|
658
|
+
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 +
|
|
452
659
|
(subjects.length ? '<label><span>Subject</span><select name="subject"><option value="">Select</option>' + subjects.map(({ record }) => '<option value="' + esc(record.id) + '">' + esc(record.title) + '</option>').join("") + '</select></label>' : "") +
|
|
453
|
-
'<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(trigger.
|
|
660
|
+
'<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>';
|
|
454
661
|
document.body.append(dialog);
|
|
455
662
|
dialog.showModal();
|
|
456
663
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
@@ -474,8 +681,14 @@ function openObligationEventDialog(trigger) {
|
|
|
474
681
|
})
|
|
475
682
|
});
|
|
476
683
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
684
|
+
const created = await response.json();
|
|
685
|
+
policyEventFeedback = {
|
|
686
|
+
name: policyEventName(trigger.eventType),
|
|
687
|
+
taskCount: created.actions?.length || trigger.steps.length
|
|
688
|
+
};
|
|
477
689
|
state = await fetchJson("/api/state");
|
|
478
690
|
dialog.close();
|
|
691
|
+
history.replaceState(null, "", "#/stage/run");
|
|
479
692
|
render();
|
|
480
693
|
} catch (error) {
|
|
481
694
|
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
|
|
@@ -488,6 +701,10 @@ function openObligationEventDialog(trigger) {
|
|
|
488
701
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
489
702
|
const audits = resourcesOfType("audit");
|
|
490
703
|
const evidence = resourcesOfType("evidence");
|
|
704
|
+
const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
|
|
705
|
+
.filter((family) => family.collectionTestRequired === false)
|
|
706
|
+
.flatMap((family) => family.operationRecordTypes || []));
|
|
707
|
+
const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
|
|
491
708
|
const requestedAudit = params.get("auditId");
|
|
492
709
|
const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
|
|
493
710
|
const today = currentDate();
|
|
@@ -499,13 +716,15 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
499
716
|
const preflight = [
|
|
500
717
|
["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
|
|
501
718
|
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
502
|
-
["Evidence",
|
|
503
|
-
["
|
|
719
|
+
["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
|
|
720
|
+
["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
|
|
721
|
+
["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"]
|
|
504
722
|
];
|
|
723
|
+
const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Complete the applicable Step 5 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
|
|
505
724
|
const dateFields = typeOne
|
|
506
725
|
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
507
726
|
: '<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>';
|
|
508
|
-
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit
|
|
727
|
+
main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Confirm the firm-agreed date or period, review filegrc Evidence and External Evidence, complete management documents and Type 2 populations, then build the indexed packet. filegrc compiles both evidence paths with their links, control matrix, history, attachments, indexes, and checksums.</p></div></div><section class="packet-preflight" aria-label="Packet readiness">' + preflight.map(([label, value, href, tone]) => '<a href="' + href + '"><span class="status-dot ' + tone + '"></span><span><small>' + esc(label) + '</small><strong>' + esc(value) + '</strong></span></a>').join("") + '</section>' + evidencePaths + renderAuditPreparation(preparation) + '<section class="panel packet-builder"><div class="panel-head"><div><p class="kicker">Evidence delivery</p><h3>' + (typeOne ? "Build the As-of Packet" : "Build the Period Packet") + '</h3></div></div><form id="packet-form">' + dateFields + '<label><span>Audit <small>required for delivery</small></span><select name="auditId"><option value="">Draft Without Audit Scope</option>' + audits.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === selected?.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><button class="button primary" type="submit" ' + (state.readOnly ? "disabled" : "") + '>' + (draft ? "Generate draft" : "Generate packet") + '</button></form><p class="packet-note">' + (state.readOnly ? "Packet generation requires the local writable renderer or the CLI." : draft ? "Drafts expose coverage gaps now. Commit a clean revision and select an audit record before delivery." : "The packet is derived under .filegrc/ and bound to the selected audit and current Git revision. filegrc checks preparation and integrity; the engagement team determines evidence sufficiency.") + '</p><div class="dialog-error" role="alert"></div></section><div id="packet-results"></div></div>';
|
|
509
728
|
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
510
729
|
const next = event.currentTarget.value;
|
|
511
730
|
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
@@ -600,20 +819,37 @@ function renderPacketResults(container, result) {
|
|
|
600
819
|
const packet = result.packet;
|
|
601
820
|
const ready = packet.readiness.status === "delivery-ready";
|
|
602
821
|
container.innerHTML = '<section class="metrics packet-metrics">' +
|
|
603
|
-
metric("
|
|
822
|
+
metric("filegrc Evidence", packet.summary.filegrcRecords, packet.summary.records + " total packet records", "neutral") +
|
|
604
823
|
metric("Obligations", packet.summary.obligationOccurrences, packet.summary.eventRuns + " event workflows", "neutral") +
|
|
605
|
-
metric("Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
|
|
824
|
+
metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
|
|
606
825
|
metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
|
|
607
|
-
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "
|
|
608
|
-
'<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(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 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(item.status) + ' · ' + esc(item.evidenceKind) + '</small></a>').join("") + '</div>' : empty("No
|
|
826
|
+
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "filegrc management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
|
|
827
|
+
'<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.evidenceKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
|
|
609
828
|
}
|
|
610
829
|
|
|
611
830
|
function obligationPreview(items) {
|
|
612
|
-
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/
|
|
831
|
+
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (item.status === "overdue" ? "bad" : item.status === "due" ? "warn" : "neutral") + '"></span><span><strong>' + esc(item.title) + '</strong><small>' + esc(timingText(item)) + '</small></span></a>').join("") + '</div>' : empty("No open obligations.");
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function distinctObligationPreviews(items, limit) {
|
|
835
|
+
const seen = new Set();
|
|
836
|
+
const result = [];
|
|
837
|
+
for (const item of items) {
|
|
838
|
+
const source = item.obligationId || item.actionItemId || item.key;
|
|
839
|
+
if (seen.has(source)) continue;
|
|
840
|
+
seen.add(source);
|
|
841
|
+
result.push(item);
|
|
842
|
+
if (result.length === limit) break;
|
|
843
|
+
}
|
|
844
|
+
return result;
|
|
613
845
|
}
|
|
614
846
|
|
|
615
847
|
function eventReminderPreview(triggers) {
|
|
616
|
-
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) =>
|
|
848
|
+
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) => {
|
|
849
|
+
const proposed = trigger.programStatus === "proposed";
|
|
850
|
+
const href = proposed ? "#/stage/run?section=events" : "#/stage/run?event=" + encodeURIComponent(trigger.eventType);
|
|
851
|
+
return '<a href="' + href + '"><strong>' + esc(policyEventName(trigger.eventType)) + '</strong><small>' + trigger.steps.length + (proposed ? ' proposed actions' : ' required actions') + '</small></a>';
|
|
852
|
+
}).join("") + '</div>' : empty("No event reminders configured.");
|
|
617
853
|
}
|
|
618
854
|
|
|
619
855
|
function windowText(item) {
|
|
@@ -628,6 +864,7 @@ function windowText(item) {
|
|
|
628
864
|
function timingText(item) {
|
|
629
865
|
if (item.canceledAction) return "Action canceled; resolve or cancel the event";
|
|
630
866
|
if (item.missingCompletion) return "Link required completion proof";
|
|
867
|
+
if (item.status === "proposed") return "Starter proposal";
|
|
631
868
|
if (item.status === "overdue" && Number.isInteger(item.hoursOverdue)) {
|
|
632
869
|
return item.hoursOverdue === 0 ? "Overdue less than 1 hour" : item.hoursOverdue + " hour" + (item.hoursOverdue === 1 ? "" : "s") + " overdue";
|
|
633
870
|
}
|
|
@@ -669,7 +906,12 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
669
906
|
const entries = resourcesOfType(type);
|
|
670
907
|
const requestedPage = Number(params.get("page"));
|
|
671
908
|
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
672
|
-
const fields = [...new Set([
|
|
909
|
+
const fields = [...new Set([
|
|
910
|
+
"title",
|
|
911
|
+
...(definition.listFields || []),
|
|
912
|
+
...(type === "control" ? ["$operationTracking"] : []),
|
|
913
|
+
...(type === "obligation" ? ["$workQueueStatus"] : [])
|
|
914
|
+
])].filter((name) => name !== "title");
|
|
673
915
|
const modelFields = { ...state.model.commonFields, ...definition.fields };
|
|
674
916
|
const filters = Object.entries(modelFields).filter(([, field]) => field.filter).map(([name, field]) => {
|
|
675
917
|
const observed = entries.flatMap(({ record }) => Array.isArray(record[name]) ? record[name] : [record[name]]).filter((value) => ["string", "number", "boolean"].includes(typeof value)).map(String);
|
|
@@ -680,7 +922,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
680
922
|
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>';
|
|
681
923
|
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>' +
|
|
682
924
|
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>';
|
|
683
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(readinessStageForType(type)?.title ||
|
|
925
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">' + esc(readinessStageForType(type)?.title || groupTitle(definition.group)) + '</p><div class="page-title-line"><h2>' + esc(titleCase(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
684
926
|
'<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>' +
|
|
685
927
|
'<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>';
|
|
686
928
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
@@ -697,7 +939,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
697
939
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
698
940
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
699
941
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
700
|
-
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)) + '">' + 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>';
|
|
942
|
+
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$operationTracking" ? controlOperationTracking(entry.record) : name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(entry.record[name], name, type)) + '</td>').join("") + '<td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 2) + '">' + empty("No records match this filter.") + '</td></tr>';
|
|
701
943
|
pagination.hidden = totalPages === 1;
|
|
702
944
|
previous.disabled = pageNumber === 1;
|
|
703
945
|
next.disabled = pageNumber === totalPages;
|
|
@@ -762,10 +1004,28 @@ function renderDetail(main, type, id) {
|
|
|
762
1004
|
const addRecordContent = recordContent && !entry.content[recordContent.slot] && !state.readOnly
|
|
763
1005
|
? '<div class="record-content-action"><button class="button" type="button" id="add-record-content">Add Record Markdown</button></div>'
|
|
764
1006
|
: "";
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
'
|
|
1007
|
+
const hasRecordBody = Boolean(narrativeContent || markdownContent);
|
|
1008
|
+
const addRecordContentAction = !hasRecordBody && addRecordContent
|
|
1009
|
+
? '<button class="button" type="button" id="add-record-content">Add Record Markdown</button>'
|
|
1010
|
+
: "";
|
|
1011
|
+
const issueActions = state.readOnly
|
|
1012
|
+
? ""
|
|
1013
|
+
: (FINDING_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-record-finding>Record finding</button>' : "")
|
|
1014
|
+
+ (ACTION_ITEM_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-add-action-item>Add task</button>' : "");
|
|
1015
|
+
const detailMain = hasRecordBody
|
|
1016
|
+
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
1017
|
+
: "";
|
|
1018
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + issueActions + addRecordContentAction + (!state.readOnly ? '<button class="button" id="edit-resource">Edit</button>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain +
|
|
1019
|
+
'<aside><section class="panel"><div class="panel-head"><h3>Metadata</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(value, name, type) + '</dd></div>').join("") + '</dl></section>' + resourceConnections(entry) + '<section class="panel"><div class="panel-head"><h3>File History</h3></div>' + (entry.history?.length ? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>' : empty("No committed history for this file.")) + '</section></aside></div></div>';
|
|
768
1020
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
1021
|
+
main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
|
|
1022
|
+
seed: issueSeed("finding", entry.record),
|
|
1023
|
+
description: "Record only a confirmed gap that needs separate remediation tracking. Keep the report details in this source record’s Markdown."
|
|
1024
|
+
}));
|
|
1025
|
+
main.querySelector("[data-add-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
1026
|
+
seed: issueSeed("action-item", entry.record),
|
|
1027
|
+
description: "Create a separate task only when this follow-up needs its own assignee, deadline, and completion proof. It will appear in Work Queue."
|
|
1028
|
+
}));
|
|
769
1029
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
770
1030
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
771
1031
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
@@ -781,6 +1041,29 @@ function renderDetail(main, type, id) {
|
|
|
781
1041
|
});
|
|
782
1042
|
}
|
|
783
1043
|
|
|
1044
|
+
function issueSeed(type, source) {
|
|
1045
|
+
const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
|
|
1046
|
+
if (type === "finding") {
|
|
1047
|
+
return {
|
|
1048
|
+
title: "Finding from " + source.title,
|
|
1049
|
+
status: "open",
|
|
1050
|
+
severity: "medium",
|
|
1051
|
+
sourceResourceId: source.id,
|
|
1052
|
+
ownerIds: owners,
|
|
1053
|
+
identifiedOn: currentDate(),
|
|
1054
|
+
controlIds: source.controlIds || (source.controlId ? [source.controlId] : []),
|
|
1055
|
+
riskIds: source.riskIds || [],
|
|
1056
|
+
systemIds: source.systemIds || []
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
title: "Follow up: " + source.title,
|
|
1061
|
+
status: "open",
|
|
1062
|
+
sourceResourceId: source.id,
|
|
1063
|
+
assigneeIds: owners
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
784
1067
|
function recordNarrative(record, fields) {
|
|
785
1068
|
return Object.entries(record).filter(([name, value]) => RECORD_TEXT_FIELDS.has(name) && fields[name]?.type === "string" && String(value || "").trim());
|
|
786
1069
|
}
|
|
@@ -836,7 +1119,6 @@ function resourceConnections(entry) {
|
|
|
836
1119
|
function navigationResourceTypes() {
|
|
837
1120
|
return [
|
|
838
1121
|
...READINESS_STAGES.flatMap((stage) => stage.sections.flatMap((section) => section.types)),
|
|
839
|
-
...ORGANIZATION_RESOURCE_TYPES,
|
|
840
1122
|
"workspace",
|
|
841
1123
|
"renderer-settings"
|
|
842
1124
|
];
|
|
@@ -845,10 +1127,8 @@ function navigationResourceTypes() {
|
|
|
845
1127
|
function renderOrganization(main) {
|
|
846
1128
|
const workspace = resourcesOfType("workspace")[0];
|
|
847
1129
|
const renderer = rendererSettingsEntry();
|
|
848
|
-
const people = resourcesOfType("person");
|
|
849
|
-
const teams = resourcesOfType("team");
|
|
850
1130
|
const organizationName = state.workspace.organizationName || "Organization";
|
|
851
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Administration</p><h2>' + esc(titleCase(organizationName)) + '</h2><p>Manage
|
|
1131
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Administration</p><h2>' + esc(titleCase(organizationName)) + '</h2><p>Manage organization settings, renderer behavior, and repository state.</p></div>' + (workspace ? '<a class="button primary" href="#/resource/workspace/' + encodeURIComponent(workspace.record.id) + '">Organization profile</a>' : "") + '</div><div class="organization-grid"><section class="panel organization-profile"><div class="panel-head"><div><p class="kicker">Organization</p><h3>Program Settings</h3></div></div><dl class="metadata"><div><dt>Name</dt><dd>' + esc(organizationName) + '</dd></div><div><dt>Timezone</dt><dd>' + esc(state.workspace.timezone) + '</dd></div><div><dt>Data model</dt><dd>Version ' + esc(state.workspace.dataModelVersion) + '</dd></div><div><dt>Repository</dt><dd>' + (state.git.available ? esc((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</dd></div></dl></section><section class="panel organization-tools"><div class="panel-head"><div><p class="kicker">Workspace</p><h3>Renderer and Repository</h3></div></div><div class="organization-links">' + (renderer ? '<a href="#/resource/renderer-settings/' + encodeURIComponent(renderer.record.id) + '"><span><strong>Renderer Settings</strong><small>Committed behavior, including onboarding</small></span><b>›</b></a>' : "") + '<a href="#/repository"><span><strong>Repository</strong><small>Validation, file changes, history, and commits</small></span><b>›</b></a></div></section></div></div>';
|
|
852
1132
|
}
|
|
853
1133
|
|
|
854
1134
|
function renderRepository(main) {
|
|
@@ -876,18 +1156,22 @@ function renderRepository(main) {
|
|
|
876
1156
|
? '<button class="button" type="button" data-git-action="pull" ' + (pullDisabled ? 'disabled title="' + esc(pullDisabled) + '"' : "") + '>Pull with rebase</button>'
|
|
877
1157
|
: "";
|
|
878
1158
|
const commitButton = !state.readOnly && state.git.available && !state.git.clean
|
|
879
|
-
? '<button class="button primary" type="button" id="commit-workspace" ' + (state.
|
|
1159
|
+
? '<button class="button primary" type="button" id="commit-workspace" ' + (!state.git.branch
|
|
1160
|
+
? 'disabled title="Check out a branch before committing"'
|
|
1161
|
+
: state.validation.ok ? "" : 'disabled title="Fix validation errors before committing"') + '>' + (hasRemote ? "Commit and push" : "Commit locally") + '</button>'
|
|
880
1162
|
: "";
|
|
881
1163
|
const pushButton = !state.readOnly && state.git.available && hasRemote
|
|
882
1164
|
? '<button class="button primary" type="button" data-git-action="push" ' + (pushDisabled ? 'disabled title="' + esc(pushDisabled) + '"' : "") + '>Push</button>'
|
|
883
1165
|
: "";
|
|
884
|
-
const repositoryInstructions =
|
|
1166
|
+
const repositoryInstructions = !state.git.branch
|
|
1167
|
+
? "This workspace is on a detached HEAD. Check out a branch before using browser commit, pull, or push."
|
|
1168
|
+
: hasRemote
|
|
885
1169
|
? "Pull remote changes with rebase, review the workspace diff, then commit and push together."
|
|
886
1170
|
: "Review the workspace diff, then commit it locally. Add a Git remote when you want browser pull and push.";
|
|
887
1171
|
const validationBody = state.validation.diagnostics.length
|
|
888
|
-
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(item.severity) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
1172
|
+
? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
|
|
889
1173
|
: empty("No validation problems.");
|
|
890
|
-
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || "Unavailable") + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
|
|
1174
|
+
main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>' + repositoryInstructions + ' Git supplies authors, timestamps, messages, revisions, and file history.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + pullButton + commitButton + pushButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Current Revision</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(state.git.upstream || "Not configured") + '</dd></div><div><dt>Remotes</dt><dd>' + esc(state.git.remotes?.join(", ") || "None configured") + '</dd></div><div><dt>Commit</dt><dd><code>' + esc(state.git.commit || "Unavailable") + '</code></dd></div><div><dt>Working tree</dt><dd>' + (state.git.clean === null ? "Unavailable" : state.git.clean ? "Clean" : "Has changes") + '</dd></div><div><dt>Generated</dt><dd>' + esc(formatLocalDateTime(state.generatedAt)) + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Uncommitted Changes</h3></div>' + (state.git.changes?.length ? '<ul class="changes">' + state.git.changes.map((change) => '<li><code>' + esc(change) + '</code></li>').join("") + '</ul>' : empty(state.git.available ? "No uncommitted changes." : state.git.message)) + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
|
|
891
1175
|
main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
|
|
892
1176
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
893
1177
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
@@ -934,7 +1218,7 @@ function openCommitDialog() {
|
|
|
934
1218
|
const dialog = document.createElement("dialog");
|
|
935
1219
|
dialog.className = "commit-dialog";
|
|
936
1220
|
dialog.setAttribute("aria-labelledby", "commit-dialog-title");
|
|
937
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this
|
|
1221
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this filegrc workspace, then push the commit to its Git remote." : "Commit every change under this filegrc workspace. Add a Git remote later when you are ready to sync it.") + ' Use a message that explains why the compliance records changed.</p><label><span>Commit message</span><input name="message" required maxlength="200" placeholder="Record quarterly access review"></label><div class="commit-files">' + state.git.changes.map((change) => '<code>' + esc(change) + '</code>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-commit="cancel">Cancel</button><button type="submit" class="button primary">' + actionLabel + '</button></div></form>';
|
|
938
1222
|
document.body.append(dialog);
|
|
939
1223
|
dialog.showModal();
|
|
940
1224
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
@@ -980,23 +1264,14 @@ function resourceGuide(type) {
|
|
|
980
1264
|
const definition = state.model.resources[type];
|
|
981
1265
|
const guidance = definition?.guidance;
|
|
982
1266
|
if (!definition || !guidance) return "";
|
|
1267
|
+
const instructions = stagePageSummary({ type });
|
|
983
1268
|
const sources = (guidance.sourceResourceIds || [])
|
|
984
1269
|
.map((id) => state.resources.find(({ record }) => record.id === id))
|
|
985
1270
|
.filter(Boolean);
|
|
986
|
-
const activityTypes = new Set(guidance.obligationActivityTypes || []);
|
|
987
|
-
const obligations = activityTypes.size
|
|
988
|
-
? resourcesOfType("obligation").filter(({ record }) => record.status === "active" && activityTypes.has(record.activityType))
|
|
989
|
-
: [];
|
|
990
1271
|
const sourceLinks = sources.length
|
|
991
1272
|
? '<div class="guide-links">' + sources.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '">' + esc(record.title) + '</a>').join("") + '</div>'
|
|
992
1273
|
: "";
|
|
993
|
-
|
|
994
|
-
? '<div class="guide-links">' + obligations.map(({ record }) => {
|
|
995
|
-
const cadence = formatCadence(record.recurrence);
|
|
996
|
-
return '<a href="#/resource/obligation/' + encodeURIComponent(record.id) + '">' + esc(record.title) + (cadence ? ' · ' + esc(cadence) : "") + '</a>';
|
|
997
|
-
}).join("") + '</div>'
|
|
998
|
-
: "";
|
|
999
|
-
return '<section class="page-guide resource-guide-popover" id="resource-guide" role="dialog" aria-label="How to use ' + esc(definition.pluralTitle) + '" hidden><div><span>Use</span><p>' + esc(definition.description) + '</p></div><div><span>Policy basis</span><p>' + esc(guidance.policyBasis) + '</p>' + sourceLinks + '</div><div><span>Timing</span><p>' + esc(guidance.cadence) + '</p>' + obligationLinks + '</div></section>';
|
|
1274
|
+
return '<section class="page-guide resource-guide-popover" id="resource-guide" role="dialog" aria-label="How to use ' + esc(definition.pluralTitle) + '" hidden><div><span>Instructions</span><p>' + esc(instructions) + '</p></div><div><span>Use</span><p>' + esc(definition.description) + '</p></div><div><span>Policy basis</span><p>' + esc(guidance.policyBasis) + '</p>' + sourceLinks + '</div></section>';
|
|
1000
1275
|
}
|
|
1001
1276
|
|
|
1002
1277
|
function setupResourceGuide(main) {
|
|
@@ -1116,114 +1391,113 @@ function requestOnboarding() {
|
|
|
1116
1391
|
|
|
1117
1392
|
function initialOnboardingDraft() {
|
|
1118
1393
|
const systemEntry = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired");
|
|
1119
|
-
const auditEntry = systemEntry
|
|
1120
|
-
? resourcesOfType("audit").find(({ record }) => ["planned", "in-progress", "fieldwork"].includes(record.status) && record.systemIds?.includes(systemEntry.record.id))
|
|
1121
|
-
: null;
|
|
1122
1394
|
const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
|
|
1123
|
-
const independentApprover = resourcesOfType("person").find(({ record }) => record.id === "person-independent-approver")?.record;
|
|
1124
1395
|
return {
|
|
1125
1396
|
systemId: systemEntry?.record.id || "",
|
|
1126
|
-
auditId: auditEntry?.record.id || "",
|
|
1127
1397
|
serviceName: systemEntry?.record.title || "",
|
|
1128
1398
|
scope: systemEntry?.record.description || "",
|
|
1129
|
-
ownerId: systemEntry?.record.ownerIds?.[0] ||
|
|
1399
|
+
ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
|
|
1130
1400
|
criticality: systemEntry?.record.criticality || "high",
|
|
1131
1401
|
dataClassification: systemEntry?.record.dataClassification || "Confidential",
|
|
1132
1402
|
internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
|
|
1133
|
-
|
|
1134
|
-
independentApproverName: independentApprover?.title === "Independent Approver" ? "" : independentApprover?.title || "",
|
|
1135
|
-
independentApproverEmail: independentApprover?.email || ""
|
|
1403
|
+
programGoal: programGoalFromKind(state.workspace.assuranceGoal)
|
|
1136
1404
|
};
|
|
1137
1405
|
}
|
|
1138
1406
|
|
|
1139
1407
|
function onboardingSteps() {
|
|
1408
|
+
const files = {
|
|
1409
|
+
target: ".repo-chip",
|
|
1410
|
+
kicker: "Mental model",
|
|
1411
|
+
title: "Files are the program",
|
|
1412
|
+
body: "You or an agent add JSON records, Markdown, and evidence attachments under data/. This renderer edits those files, and Git records their history.",
|
|
1413
|
+
points: [
|
|
1414
|
+
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1415
|
+
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1416
|
+
"Repository pulls with rebase, then commits and pushes reviewed changes together.",
|
|
1417
|
+
"Agents and terminal users run git pull --rebase, git commit, and git push directly.",
|
|
1418
|
+
"The dashboard derives program status from the current repository state."
|
|
1419
|
+
]
|
|
1420
|
+
};
|
|
1421
|
+
const path = {
|
|
1422
|
+
target: ".readiness-map",
|
|
1423
|
+
kicker: "Program model",
|
|
1424
|
+
title: "Follow the audit chain",
|
|
1425
|
+
body: "The shortest dependable path is to define scope, approve policies, implement controls, prepare the evidence process, and operate the program before audit fieldwork begins.",
|
|
1426
|
+
points: [
|
|
1427
|
+
"Scope starts with the people and oversight team, applicable criteria, commitments, material vendors, and in-scope systems.",
|
|
1428
|
+
"Program operation includes current risk assessments and risks, which may add or change controls as conditions change.",
|
|
1429
|
+
"Evidence preparation means cataloging authoritative systems, documenting extraction, and testing captures before the candidate period.",
|
|
1430
|
+
"The CPA firm, formal report period, fieldwork, and final report are the last stage. Engage earlier only when timing or scope needs outside input."
|
|
1431
|
+
]
|
|
1432
|
+
};
|
|
1433
|
+
const obligations = {
|
|
1434
|
+
target: ".obligation-panel",
|
|
1435
|
+
kicker: "Obligations",
|
|
1436
|
+
title: "Work the policy queue",
|
|
1437
|
+
body: "Recurring policy work appears as upcoming, due, or overdue. Each item shows the full allowed completion range, the first overdue date, and a live countdown to that cutoff.",
|
|
1438
|
+
points: [
|
|
1439
|
+
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1440
|
+
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1441
|
+
"The UI and filegrc CLI use the same calculation."
|
|
1442
|
+
]
|
|
1443
|
+
};
|
|
1444
|
+
const events = {
|
|
1445
|
+
target: ".event-reminder-panel",
|
|
1446
|
+
kicker: "Triggered work",
|
|
1447
|
+
title: "Complete a checklist when key events occur",
|
|
1448
|
+
body: "Use an event reminder for a new worker, role change, departure, personal device, vendor change or incident, material system or data-use change, or security incident. One action item is created for every policy requirement, with its own owner, evidence, due range, and cutoff.",
|
|
1449
|
+
points: [
|
|
1450
|
+
"The checklist stays open until every action is done and has the requested completion record or evidence.",
|
|
1451
|
+
"Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
|
|
1452
|
+
"Every action has a policy-based cutoff or a reasonable default deadline for the event.",
|
|
1453
|
+
"Agents start the identical workflow with the filegrc CLI."
|
|
1454
|
+
]
|
|
1455
|
+
};
|
|
1456
|
+
const reportTypes = {
|
|
1457
|
+
target: null,
|
|
1458
|
+
kicker: "SOC 2 report types",
|
|
1459
|
+
title: "Choose the report goal",
|
|
1460
|
+
body: [
|
|
1461
|
+
"SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
|
|
1462
|
+
"Most customer requests focus on Security. Add Availability, Processing Integrity, Confidentiality, or Privacy only when the service and customer need call for them."
|
|
1463
|
+
],
|
|
1464
|
+
sections: [
|
|
1465
|
+
{
|
|
1466
|
+
title: "Type 1",
|
|
1467
|
+
body: "A CPA evaluates whether the controls are suitably designed and implemented at a point in time. Type 1 is optional before Type 2, but it can help with an urgent customer request."
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
title: "Type 2",
|
|
1471
|
+
body: "A CPA evaluates whether the controls operated consistently throughout an agreed review period, often six months. Dated evidence must cover that period."
|
|
1472
|
+
}
|
|
1473
|
+
],
|
|
1474
|
+
afterSections: "Most evidence comes from production, identity, monitoring, and business systems. filegrc records where it comes from and how it was collected."
|
|
1475
|
+
};
|
|
1476
|
+
const audit = {
|
|
1477
|
+
target: ".audit-panel",
|
|
1478
|
+
kicker: "Final stage",
|
|
1479
|
+
title: "Engage the firm and prepare fieldwork",
|
|
1480
|
+
body: "Once the program is ready and evidence collection is running, record the CPA firm and the agreed scope and period. Then reconcile populations, answer requests, and generate the delivery packet.",
|
|
1481
|
+
points: [
|
|
1482
|
+
"The audit record holds the firm-agreed date or period; the workspace keeps management's earlier candidate dates.",
|
|
1483
|
+
"Audit Readiness identifies missing management documents, populations, exact-period evidence, and request work.",
|
|
1484
|
+
"The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1485
|
+
]
|
|
1486
|
+
};
|
|
1487
|
+
const setup = {
|
|
1488
|
+
target: null,
|
|
1489
|
+
kicker: "Initial scope",
|
|
1490
|
+
title: "Describe the service you plan to audit",
|
|
1491
|
+
body: "This records the management goal and creates the first in-scope system. It does not create an audit engagement. Next, finish Step 1 by adding the real reviewers and operators, finishing the oversight team, and confirming the criteria, commitments, vendors, and systems."
|
|
1492
|
+
};
|
|
1140
1493
|
return [
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
],
|
|
1149
|
-
sections: [
|
|
1150
|
-
{
|
|
1151
|
-
title: "Type 1",
|
|
1152
|
-
body: "An auditor evaluates whether your policies and controls are suitably designed and in place at a specific point in time. It is a snapshot rather than a test of ongoing operation. Type 1 is not required for Type 2, but Type 1 can be helpful as a short-term solution for urgent customer requests."
|
|
1153
|
-
},
|
|
1154
|
-
{
|
|
1155
|
-
title: "Type 2",
|
|
1156
|
-
body: "An auditor evaluates whether your controls operated consistently throughout a review period, most commonly six months. You provide dated evidence showing that the policies were followed across that period."
|
|
1157
|
-
}
|
|
1158
|
-
],
|
|
1159
|
-
afterSections: "Just about all of the evidence needed for these audits will come from your software's production infrastructure + monitoring, or the business operations tracked in FileGRC."
|
|
1160
|
-
},
|
|
1161
|
-
{
|
|
1162
|
-
target: ".repo-chip",
|
|
1163
|
-
kicker: "Mental model",
|
|
1164
|
-
title: "Files are the program",
|
|
1165
|
-
body: "You or an agent add JSON records, Markdown, and evidence attachments under data/. This renderer edits those files, and Git records their history.",
|
|
1166
|
-
points: [
|
|
1167
|
-
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1168
|
-
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1169
|
-
"Repository pulls with rebase, then commits and pushes reviewed changes together.",
|
|
1170
|
-
"Agents and terminal users run git pull --rebase, git commit, and git push directly.",
|
|
1171
|
-
"The dashboard derives program status from the current repository state."
|
|
1172
|
-
]
|
|
1173
|
-
},
|
|
1174
|
-
{
|
|
1175
|
-
target: ".readiness-map",
|
|
1176
|
-
kicker: "Program model",
|
|
1177
|
-
title: "Follow the audit chain",
|
|
1178
|
-
body: "The program runs in one direction: define the system scope, map the criteria, adopt policies, operate controls, retain dated proof, and give the auditor a coherent record of the period.",
|
|
1179
|
-
points: [
|
|
1180
|
-
"Criteria are external expectations. Policies are company rules. Controls state the repeatable work that meets both.",
|
|
1181
|
-
"Inventories and dated records of reviews, assessments, meetings, tests, and incidents show that controls are operating.",
|
|
1182
|
-
"Evidence proves an occurrence; Git proves the history of every artifact."
|
|
1183
|
-
]
|
|
1184
|
-
},
|
|
1185
|
-
{
|
|
1186
|
-
target: ".obligation-panel",
|
|
1187
|
-
kicker: "Obligations",
|
|
1188
|
-
title: "Work the policy queue",
|
|
1189
|
-
body: "Recurring policy work appears as upcoming, due, or overdue. Each item shows the full allowed completion range, the first overdue date, and a live countdown to that cutoff.",
|
|
1190
|
-
points: [
|
|
1191
|
-
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1192
|
-
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1193
|
-
"The UI and FileGRC CLI use the same calculation."
|
|
1194
|
-
]
|
|
1195
|
-
},
|
|
1196
|
-
{
|
|
1197
|
-
target: ".event-reminder-panel",
|
|
1198
|
-
kicker: "Triggered work",
|
|
1199
|
-
title: "Complete a checklist when key events occur",
|
|
1200
|
-
body: "Use an event reminder for a new worker, role change, departure, personal device, vendor change or incident, material system or data-use change, or security incident. One action item is created for every policy requirement, with its own owner, evidence, due range, and cutoff.",
|
|
1201
|
-
points: [
|
|
1202
|
-
"The checklist stays open until every action is done and has the requested completion record or evidence.",
|
|
1203
|
-
"Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
|
|
1204
|
-
"Every action has a policy-based cutoff or a reasonable default deadline for the event.",
|
|
1205
|
-
"Agents start the identical workflow with the FileGRC CLI."
|
|
1206
|
-
]
|
|
1207
|
-
},
|
|
1208
|
-
{
|
|
1209
|
-
target: ".audit-panel",
|
|
1210
|
-
kicker: "Audit",
|
|
1211
|
-
title: "Plan the engagement and generate evidence",
|
|
1212
|
-
body: "Engage an independent CPA firm early, record the agreed scope and date or period, then generate a scoped delivery packet from the same files used to run the program.",
|
|
1213
|
-
points: [
|
|
1214
|
-
"Record the firm, scope, exact date or period, and each request in the audit.",
|
|
1215
|
-
"Audit Readiness identifies missing management work, source systems, and evidence, including what to export and when.",
|
|
1216
|
-
"For Type 2, reconcile every period population after close. A zero count still needs its source export and query.",
|
|
1217
|
-
"Generate the same indexed packet from the UI or CLI, with scoped records, Markdown, fixed attachments, delivery indexes, and checksums.",
|
|
1218
|
-
"FileGRC checks management preparation and packet integrity. The auditor selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1219
|
-
]
|
|
1220
|
-
},
|
|
1221
|
-
{
|
|
1222
|
-
target: null,
|
|
1223
|
-
kicker: "Initial scope",
|
|
1224
|
-
title: "Describe the service you plan to audit",
|
|
1225
|
-
body: "This creates or updates one in-scope system and, if selected, one planned audit. It also assigns the external independent reviewer required to approve policies and chair oversight. The reviewer must be separate from the policy owner and control operators."
|
|
1226
|
-
}
|
|
1494
|
+
files,
|
|
1495
|
+
path,
|
|
1496
|
+
obligations,
|
|
1497
|
+
events,
|
|
1498
|
+
reportTypes,
|
|
1499
|
+
audit,
|
|
1500
|
+
setup
|
|
1227
1501
|
];
|
|
1228
1502
|
}
|
|
1229
1503
|
|
|
@@ -1242,7 +1516,10 @@ function renderOnboardingStep() {
|
|
|
1242
1516
|
const body = onboardingStep === steps.length - 1
|
|
1243
1517
|
? onboardingSetupForm()
|
|
1244
1518
|
: description + explanation + afterSections;
|
|
1245
|
-
|
|
1519
|
+
const finalActions = onboardingStep === steps.length - 1
|
|
1520
|
+
? '<button class="button" type="button" data-onboarding="draft">Save draft</button><button class="button primary" type="button" data-onboarding="next">Complete setup</button>'
|
|
1521
|
+
: '<button class="button primary" type="button" data-onboarding="next">Next</button>';
|
|
1522
|
+
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-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 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>';
|
|
1246
1523
|
onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
|
|
1247
1524
|
onboardingDialog.querySelector('[data-onboarding="back"]')?.addEventListener("click", () => {
|
|
1248
1525
|
captureOnboardingForm();
|
|
@@ -1250,12 +1527,13 @@ function renderOnboardingStep() {
|
|
|
1250
1527
|
renderOnboardingStep();
|
|
1251
1528
|
});
|
|
1252
1529
|
onboardingDialog.querySelector('[data-onboarding="next"]').addEventListener("click", () => {
|
|
1253
|
-
if (onboardingStep === steps.length - 1) saveOnboarding();
|
|
1530
|
+
if (onboardingStep === steps.length - 1) saveOnboarding(false);
|
|
1254
1531
|
else {
|
|
1255
1532
|
onboardingStep += 1;
|
|
1256
1533
|
renderOnboardingStep();
|
|
1257
1534
|
}
|
|
1258
1535
|
});
|
|
1536
|
+
onboardingDialog.querySelector('[data-onboarding="draft"]')?.addEventListener("click", () => saveOnboarding(true));
|
|
1259
1537
|
const target = onboardingTarget(step);
|
|
1260
1538
|
if (target) {
|
|
1261
1539
|
target.classList.add("onboarding-focus");
|
|
@@ -1275,148 +1553,60 @@ function renderOnboardingStep() {
|
|
|
1275
1553
|
|
|
1276
1554
|
function onboardingSetupForm() {
|
|
1277
1555
|
const people = resourcesOfType("person").filter(({ record }) => record.status === "active");
|
|
1278
|
-
const contactDomain = people
|
|
1279
|
-
.map(({ record }) => record.email?.split("@")[1])
|
|
1280
|
-
.find(Boolean);
|
|
1281
|
-
const approverEmailPlaceholder = contactDomain ? "reviewer@" + contactDomain : "Reviewer email";
|
|
1282
1556
|
const classifications = Object.keys(state.workspace.classificationDefinitions || {});
|
|
1283
1557
|
if (onboardingDraft.dataClassification && !classifications.includes(onboardingDraft.dataClassification)) {
|
|
1284
1558
|
classifications.push(onboardingDraft.dataClassification);
|
|
1285
1559
|
}
|
|
1286
1560
|
const currentSystem = onboardingDraft.systemId ? state.resources.find(({ record }) => record.id === onboardingDraft.systemId)?.record : null;
|
|
1287
|
-
const currentAudit = onboardingDraft.auditId ? state.resources.find(({ record }) => record.id === onboardingDraft.auditId)?.record : null;
|
|
1288
1561
|
const existing = [
|
|
1289
1562
|
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1290
|
-
|
|
1563
|
+
"Records a management program goal without creating an audit engagement."
|
|
1291
1564
|
].filter(Boolean).join(" ");
|
|
1292
|
-
const gitStatus = state.git.available && state.git.
|
|
1565
|
+
const gitStatus = state.git.available && !state.git.branch
|
|
1566
|
+
? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Detached HEAD detected</strong><small>Setup changes can be saved, but browser commits, pulls, and pushes are disabled until you check out a branch.</small></span></div>'
|
|
1567
|
+
: state.git.available && state.git.remotes?.length
|
|
1293
1568
|
? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Git repository and remote detected</strong><small>Setup changes will appear in the workspace diff. Browser commits push to the configured remote.</small></span></div>'
|
|
1294
1569
|
: state.git.available
|
|
1295
1570
|
? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git remote needed</strong><small>Saving and local commits still work. Add a remote before the browser can push.</small></span></div>'
|
|
1296
1571
|
: '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Saving still works. Run <code>git init</code> at the workspace root before your first compliance commit.</small></span></div>';
|
|
1297
|
-
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label
|
|
1572
|
+
return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Save draft keeps the system planned. Saving writes JSON files but does not commit them. Complete the remaining Step 1 pages next.</p>';
|
|
1298
1573
|
}
|
|
1299
1574
|
|
|
1300
1575
|
function captureOnboardingForm() {
|
|
1301
1576
|
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1302
1577
|
if (!form) return;
|
|
1303
1578
|
const data = new FormData(form);
|
|
1304
|
-
for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "
|
|
1579
|
+
for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "programGoal"]) {
|
|
1305
1580
|
onboardingDraft[name] = String(data.get(name) || "").trim();
|
|
1306
1581
|
}
|
|
1307
1582
|
}
|
|
1308
1583
|
|
|
1309
|
-
async function saveOnboarding() {
|
|
1584
|
+
async function saveOnboarding(draft = false) {
|
|
1310
1585
|
if (onboardingBusy) return;
|
|
1311
1586
|
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1312
1587
|
if (!form?.reportValidity()) return;
|
|
1313
1588
|
captureOnboardingForm();
|
|
1314
|
-
|
|
1315
|
-
const sameName = policyOwner?.title?.trim().toLowerCase() === onboardingDraft.independentApproverName.toLowerCase();
|
|
1316
|
-
const sameEmail = policyOwner?.email?.trim().toLowerCase() === onboardingDraft.independentApproverEmail.toLowerCase();
|
|
1317
|
-
if (sameName || sameEmail) {
|
|
1318
|
-
const field = form.elements[sameName ? "independentApproverName" : "independentApproverEmail"];
|
|
1319
|
-
field.setCustomValidity("The independent approver must be a different person from the policy owner.");
|
|
1320
|
-
field.reportValidity();
|
|
1321
|
-
field.addEventListener("input", () => field.setCustomValidity(""), { once: true });
|
|
1322
|
-
return;
|
|
1323
|
-
}
|
|
1324
|
-
setOnboardingBusy(true, "Saving…");
|
|
1589
|
+
setOnboardingBusy(true, draft ? "Saving draft…" : "Completing…");
|
|
1325
1590
|
try {
|
|
1591
|
+
const response = await localFetch("/api/setup", {
|
|
1592
|
+
method: "POST",
|
|
1593
|
+
headers: { "content-type": "application/json" },
|
|
1594
|
+
body: JSON.stringify({
|
|
1595
|
+
serviceName: onboardingDraft.serviceName,
|
|
1596
|
+
boundary: onboardingDraft.scope,
|
|
1597
|
+
ownerId: onboardingDraft.ownerId,
|
|
1598
|
+
criticality: onboardingDraft.criticality,
|
|
1599
|
+
dataClassification: onboardingDraft.dataClassification,
|
|
1600
|
+
internetExposed: onboardingDraft.internetExposed === "true",
|
|
1601
|
+
programGoal: onboardingDraft.programGoal,
|
|
1602
|
+
systemId: onboardingDraft.systemId,
|
|
1603
|
+
draft
|
|
1604
|
+
})
|
|
1605
|
+
});
|
|
1606
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1326
1607
|
state = await fetchJson("/api/state");
|
|
1327
|
-
const approverEntry = state.resources.find(({ record }) => record.id === "person-independent-approver");
|
|
1328
|
-
const oversightTeamExists = state.resources.some(({ record }) => record.id === "team-security-risk-oversight");
|
|
1329
|
-
const independentApprover = {
|
|
1330
|
-
...(approverEntry?.record || {}),
|
|
1331
|
-
schemaVersion: 1,
|
|
1332
|
-
id: "person-independent-approver",
|
|
1333
|
-
type: "person",
|
|
1334
|
-
title: onboardingDraft.independentApproverName,
|
|
1335
|
-
status: "external",
|
|
1336
|
-
email: onboardingDraft.independentApproverEmail,
|
|
1337
|
-
role: "External Security and Risk Oversight Reviewer",
|
|
1338
|
-
employmentType: "external-reviewer",
|
|
1339
|
-
...(oversightTeamExists ? { teamIds: ["team-security-risk-oversight"] } : {})
|
|
1340
|
-
};
|
|
1341
|
-
await writeOnboardingResource(independentApprover, approverEntry);
|
|
1342
|
-
state = await fetchJson("/api/state");
|
|
1343
|
-
|
|
1344
|
-
let systemEntry = onboardingDraft.systemId
|
|
1345
|
-
? state.resources.find(({ record }) => record.type === "system" && record.id === onboardingDraft.systemId)
|
|
1346
|
-
: state.resources.find(({ record }) => (
|
|
1347
|
-
record.type === "system"
|
|
1348
|
-
&& record.inScope === true
|
|
1349
|
-
&& record.title.trim().toLowerCase() === onboardingDraft.serviceName.toLowerCase()
|
|
1350
|
-
));
|
|
1351
|
-
const systemId = systemEntry?.record.id || createResourceId("system", onboardingDraft.serviceName, state.resources.map(({ record }) => record.id));
|
|
1352
|
-
onboardingDraft.systemId = systemId;
|
|
1353
|
-
const system = {
|
|
1354
|
-
...(systemEntry?.record || {}),
|
|
1355
|
-
schemaVersion: 1,
|
|
1356
|
-
id: systemId,
|
|
1357
|
-
type: "system",
|
|
1358
|
-
title: onboardingDraft.serviceName,
|
|
1359
|
-
status: systemEntry?.record.status || "active",
|
|
1360
|
-
criticality: onboardingDraft.criticality,
|
|
1361
|
-
ownerIds: [onboardingDraft.ownerId],
|
|
1362
|
-
description: onboardingDraft.scope,
|
|
1363
|
-
systemKind: systemEntry?.record.systemKind || "service",
|
|
1364
|
-
dataClassification: onboardingDraft.dataClassification,
|
|
1365
|
-
internetExposed: onboardingDraft.internetExposed === "true",
|
|
1366
|
-
inScope: true
|
|
1367
|
-
};
|
|
1368
|
-
await writeOnboardingResource(system, systemEntry);
|
|
1369
|
-
state = await fetchJson("/api/state");
|
|
1370
|
-
systemEntry = state.resources.find(({ record }) => record.id === systemId);
|
|
1371
|
-
|
|
1372
|
-
const controlsToLink = resourcesOfType("control").filter(({ record }) => (
|
|
1373
|
-
!["not-applicable", "retired"].includes(record.status)
|
|
1374
|
-
&& !(record.systemIds || []).includes(systemId)
|
|
1375
|
-
));
|
|
1376
|
-
if (controlsToLink.length) {
|
|
1377
|
-
for (const controlEntry of controlsToLink) {
|
|
1378
|
-
await writeOnboardingResource({
|
|
1379
|
-
...controlEntry.record,
|
|
1380
|
-
systemIds: [...new Set([...(controlEntry.record.systemIds || []), systemId])]
|
|
1381
|
-
}, controlEntry);
|
|
1382
|
-
}
|
|
1383
|
-
state = await fetchJson("/api/state");
|
|
1384
|
-
}
|
|
1385
|
-
if (onboardingDraft.auditGoal !== "none") {
|
|
1386
|
-
let auditEntry = onboardingDraft.auditId
|
|
1387
|
-
? state.resources.find(({ record }) => record.type === "audit" && record.id === onboardingDraft.auditId)
|
|
1388
|
-
: state.resources.find(({ record }) => (
|
|
1389
|
-
record.type === "audit"
|
|
1390
|
-
&& record.auditKind === auditKindFromGoal(onboardingDraft.auditGoal)
|
|
1391
|
-
&& record.title === onboardingDraft.serviceName + " " + auditTitleFromGoal(onboardingDraft.auditGoal)
|
|
1392
|
-
));
|
|
1393
|
-
const kind = auditKindFromGoal(onboardingDraft.auditGoal);
|
|
1394
|
-
const title = onboardingDraft.serviceName + " " + auditTitleFromGoal(onboardingDraft.auditGoal);
|
|
1395
|
-
const auditId = auditEntry?.record.id || createResourceId("audit", title, state.resources.map(({ record }) => record.id));
|
|
1396
|
-
onboardingDraft.auditId = auditId;
|
|
1397
|
-
const audit = {
|
|
1398
|
-
...(auditEntry?.record || {}),
|
|
1399
|
-
schemaVersion: 1,
|
|
1400
|
-
id: auditId,
|
|
1401
|
-
type: "audit",
|
|
1402
|
-
title: auditEntry?.record.title || title,
|
|
1403
|
-
status: auditEntry?.record.status || "planned",
|
|
1404
|
-
auditKind: kind,
|
|
1405
|
-
frameworkIds: auditEntry?.record.frameworkIds || resourcesOfType("framework").filter(({ record }) => record.status === "active").map(({ record }) => record.id),
|
|
1406
|
-
scope: onboardingDraft.scope,
|
|
1407
|
-
ownerIds: [onboardingDraft.ownerId],
|
|
1408
|
-
systemIds: [...new Set([...(auditEntry?.record.systemIds || []), systemId])],
|
|
1409
|
-
requirementIds: auditEntry?.record.requirementIds || resourcesOfType("requirement").filter(({ record }) => record.applicability === "applicable").map(({ record }) => record.id),
|
|
1410
|
-
controlIds: auditEntry?.record.controlIds || resourcesOfType("control").filter(({ record }) => !["not-applicable", "retired"].includes(record.status)).map(({ record }) => record.id),
|
|
1411
|
-
contactIds: [...new Set([...(auditEntry?.record.contactIds || []), onboardingDraft.ownerId])]
|
|
1412
|
-
};
|
|
1413
|
-
await writeOnboardingResource(audit, auditEntry);
|
|
1414
|
-
state = await fetchJson("/api/state");
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
await persistOnboardingPreference(false);
|
|
1418
1608
|
closeOnboarding();
|
|
1419
|
-
history.replaceState(null, "", "#/");
|
|
1609
|
+
history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
|
|
1420
1610
|
render();
|
|
1421
1611
|
} catch (error) {
|
|
1422
1612
|
setOnboardingBusy(false);
|
|
@@ -1442,11 +1632,11 @@ async function cancelOnboarding() {
|
|
|
1442
1632
|
async function persistOnboardingPreference(showOnboarding) {
|
|
1443
1633
|
const entry = rendererSettingsEntry();
|
|
1444
1634
|
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1445
|
-
await
|
|
1635
|
+
await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry);
|
|
1446
1636
|
state = await fetchJson("/api/state");
|
|
1447
1637
|
}
|
|
1448
1638
|
|
|
1449
|
-
async function
|
|
1639
|
+
async function writeRendererSettingsResource(record, entry) {
|
|
1450
1640
|
const url = entry
|
|
1451
1641
|
? "/api/resource/" + encodeURIComponent(record.type) + "/" + encodeURIComponent(record.id)
|
|
1452
1642
|
: "/api/resources";
|
|
@@ -1458,6 +1648,24 @@ async function writeOnboardingResource(record, entry) {
|
|
|
1458
1648
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1459
1649
|
}
|
|
1460
1650
|
|
|
1651
|
+
async function toggleStagePageCompletion(button) {
|
|
1652
|
+
const entry = rendererSettingsEntry();
|
|
1653
|
+
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1654
|
+
const completed = new Set(entry.record.completedStagePageIds || []);
|
|
1655
|
+
const pageId = button.dataset.stagePageCompletion;
|
|
1656
|
+
if (button.dataset.complete === "true") {
|
|
1657
|
+
completed.delete(pageId);
|
|
1658
|
+
(STAGE_PAGE_ID_ALIASES[pageId] || []).forEach((id) => completed.delete(id));
|
|
1659
|
+
} else {
|
|
1660
|
+
completed.add(pageId);
|
|
1661
|
+
}
|
|
1662
|
+
await writeRendererSettingsResource({
|
|
1663
|
+
...entry.record,
|
|
1664
|
+
completedStagePageIds: [...completed].sort()
|
|
1665
|
+
}, entry);
|
|
1666
|
+
state = await fetchJson("/api/state");
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1461
1669
|
function setOnboardingBusy(busy, label = "") {
|
|
1462
1670
|
if (!onboardingDialog) return;
|
|
1463
1671
|
onboardingBusy = busy;
|
|
@@ -1563,21 +1771,13 @@ function closeOnboarding() {
|
|
|
1563
1771
|
onboardingDialog.close();
|
|
1564
1772
|
}
|
|
1565
1773
|
|
|
1566
|
-
function
|
|
1774
|
+
function programGoalFromKind(kind) {
|
|
1567
1775
|
if (kind === "soc-2-type-1") return "type-1";
|
|
1568
1776
|
if (kind === "soc-2-type-2") return "type-2";
|
|
1569
1777
|
if (kind === "readiness") return "readiness";
|
|
1570
1778
|
return "none";
|
|
1571
1779
|
}
|
|
1572
1780
|
|
|
1573
|
-
function auditKindFromGoal(goal) {
|
|
1574
|
-
return goal === "type-1" ? "soc-2-type-1" : goal === "type-2" ? "soc-2-type-2" : "readiness";
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
function auditTitleFromGoal(goal) {
|
|
1578
|
-
return goal === "type-1" ? "SOC 2 Type 1" : goal === "type-2" ? "SOC 2 Type 2" : "SOC 2 readiness assessment";
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
1781
|
function openEditor(type, entry = null, options = {}) {
|
|
1582
1782
|
const definition = state.model.resources[type];
|
|
1583
1783
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
@@ -1591,7 +1791,13 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1591
1791
|
...Object.entries(state.model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
|
|
1592
1792
|
...(definition.required || [])
|
|
1593
1793
|
]);
|
|
1594
|
-
const
|
|
1794
|
+
const oneOfGroups = (definition.oneOf || []).map((group) => (
|
|
1795
|
+
Array.isArray(group) ? { fields: group } : group
|
|
1796
|
+
));
|
|
1797
|
+
const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
|
|
1798
|
+
const activeOneOf = new Set(oneOfGroups
|
|
1799
|
+
.filter((group) => !group.when || conditionMatches(record, group.when))
|
|
1800
|
+
.flatMap((group) => group.fields || []));
|
|
1595
1801
|
const names = [...new Set([
|
|
1596
1802
|
"title",
|
|
1597
1803
|
...required,
|
|
@@ -1603,23 +1809,29 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1603
1809
|
dialog.className = "editor";
|
|
1604
1810
|
dialog.setAttribute("aria-labelledby", "resource-editor-title");
|
|
1605
1811
|
const activeMarkdown = markdownDefinitions.filter((markdown) => (
|
|
1606
|
-
markdown.required || markdown.oneOf || !entry || entry.content?.[markdown.name]
|
|
1812
|
+
markdown.required || markdown.requiredWhen || markdown.oneOf || !entry || entry.content?.[markdown.name]
|
|
1607
1813
|
));
|
|
1608
1814
|
const recordContent = recordContentDefinition(type);
|
|
1609
1815
|
const recordContentItem = recordContent ? entry?.content?.[recordContent.slot] : null;
|
|
1610
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : options.obligationCompletion ? "Record obligation work" : "Create record") + '</p><h2 id="resource-editor-title">' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '</h2></div><button type="button" class="icon-button" data-editor-dismiss aria-label="Close">×</button></div><p>' + esc(options.description || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.") + '</p><div class="form-grid">' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name))).join("") + '</div>' +
|
|
1816
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + (entry ? "Edit record" : options.obligationCompletion ? "Record obligation work" : "Create record") + '</p><h2 id="resource-editor-title">' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '</h2></div><button type="button" class="icon-button" data-editor-dismiss aria-label="Close">×</button></div><p>' + esc(options.description || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file.") + '</p><div class="form-grid">' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name))).join("") + '</div>' +
|
|
1611
1817
|
activeMarkdown.map((markdown) => {
|
|
1612
1818
|
const generated = !entry?.content?.[markdown.name];
|
|
1613
|
-
const source = entry?.content?.[markdown.name]?.source
|
|
1614
|
-
|
|
1615
|
-
|
|
1819
|
+
const source = entry?.content?.[markdown.name]?.source
|
|
1820
|
+
?? (markdown.oneOf ? "" : "# " + (record.title || "New " + definition.title) + "\n\nDescribe this " + definition.title.toLowerCase() + " here.\n");
|
|
1821
|
+
const requiredNow = markdown.required || conditionMatches(record, markdown.requiredWhen);
|
|
1822
|
+
const requiredMark = markdown.required || markdown.requiredWhen
|
|
1823
|
+
? '<span class="required-mark" ' + (requiredNow ? "" : "hidden") + '>Required</span>'
|
|
1824
|
+
: markdown.oneOf
|
|
1825
|
+
? '<span class="required-mark" ' + (activeOneOf.has("$markdown:" + markdown.name) ? "" : "hidden") + '>One Required</span>'
|
|
1826
|
+
: "";
|
|
1827
|
+
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>';
|
|
1616
1828
|
}).join("") + renderRecordContentEditor(type, entry, options) +
|
|
1617
1829
|
'<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
|
|
1618
1830
|
document.body.append(dialog);
|
|
1619
1831
|
dialog.showModal();
|
|
1620
1832
|
dialog.addEventListener("close", () => dialog.remove());
|
|
1621
1833
|
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
1622
|
-
wireEditorRequirements(dialog, record, fields,
|
|
1834
|
+
wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
|
|
1623
1835
|
dialog.querySelector(".advanced-editor textarea").addEventListener("input", () => {
|
|
1624
1836
|
dialog.dataset.jsonDirty = "true";
|
|
1625
1837
|
dialog.querySelector("form").noValidate = true;
|
|
@@ -1714,7 +1926,9 @@ function seedRecord(type, definition) {
|
|
|
1714
1926
|
else if (field.values?.length) record[name] = field.values[0];
|
|
1715
1927
|
else record[name] = "";
|
|
1716
1928
|
}
|
|
1717
|
-
for (const
|
|
1929
|
+
for (const group of definition.oneOf || []) {
|
|
1930
|
+
const choices = Array.isArray(group) ? group : group.fields || [];
|
|
1931
|
+
if (!Array.isArray(group) && !conditionMatches(record, group.when)) continue;
|
|
1718
1932
|
if (choices.some((name) => record[name] !== undefined)) continue;
|
|
1719
1933
|
const name = choices.find((candidate) => fields[candidate]);
|
|
1720
1934
|
if (name) record[name] = fields[name].type === "array" ? [] : "";
|
|
@@ -1724,12 +1938,16 @@ function seedRecord(type, definition) {
|
|
|
1724
1938
|
|
|
1725
1939
|
function dedicatedMarkdownDefinitions(type) {
|
|
1726
1940
|
const definition = state.model.resources[type];
|
|
1727
|
-
const choices = new Set((definition.oneOf || [])
|
|
1941
|
+
const choices = new Set((definition.oneOf || [])
|
|
1942
|
+
.flatMap((group) => Array.isArray(group) ? group : group.fields || [])
|
|
1943
|
+
.filter((name) => name.startsWith("$markdown:"))
|
|
1944
|
+
.map((name) => name.slice("$markdown:".length)));
|
|
1728
1945
|
return Object.entries(definition.markdown || {}).map(([name, markdown]) => ({
|
|
1729
1946
|
name,
|
|
1730
1947
|
label: markdown.label || humanize(name),
|
|
1731
1948
|
primary: Boolean(markdown.primary),
|
|
1732
1949
|
required: Boolean(markdown.required),
|
|
1950
|
+
requiredWhen: markdown.requiredWhen || null,
|
|
1733
1951
|
oneOf: choices.has(name)
|
|
1734
1952
|
}));
|
|
1735
1953
|
}
|
|
@@ -1757,10 +1975,10 @@ function renderRecordContentEditor(type, entry, options) {
|
|
|
1757
1975
|
return '<details class="record-content-details" ' + (open ? "open" : "") + '><summary>' + (item ? "Record Markdown" : "Add Record Markdown") + '</summary><p>Use this when the structured fields do not capture the full record.</p>' + editor + '</details>';
|
|
1758
1976
|
}
|
|
1759
1977
|
|
|
1760
|
-
function editorField(type, name, field, value, required, editing, oneOfRequired = false) {
|
|
1978
|
+
function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired) {
|
|
1761
1979
|
const label = fieldLabel(type, name);
|
|
1762
1980
|
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
1763
|
-
? '<span class="required-mark" ' + (required ||
|
|
1981
|
+
? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
1764
1982
|
: "";
|
|
1765
1983
|
const help = name === "title"
|
|
1766
1984
|
? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
|
|
@@ -1819,7 +2037,7 @@ function fieldWrap(name, kind, label, requiredMark, control, help, required) {
|
|
|
1819
2037
|
return '<div class="form-field" data-field-group="' + esc(name) + '" data-kind="' + esc(kind) + '" data-required="' + (required ? "true" : "false") + '"><div class="field-label" id="' + esc(labelId) + '">' + esc(label) + requiredMark + '</div>' + labelledControl + (help ? '<small>' + esc(help) + '</small>' : "") + '</div>';
|
|
1820
2038
|
}
|
|
1821
2039
|
|
|
1822
|
-
function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
2040
|
+
function wireEditorRequirements(dialog, base, fields, oneOfGroups, markdownDefinitions = []) {
|
|
1823
2041
|
const refreshGroup = (group, required) => {
|
|
1824
2042
|
group.dataset.required = required ? "true" : "false";
|
|
1825
2043
|
const mark = group.querySelector(".required-mark");
|
|
@@ -1853,13 +2071,29 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
|
1853
2071
|
if (!field.requiredWhen) continue;
|
|
1854
2072
|
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
1855
2073
|
if (!group) continue;
|
|
1856
|
-
const
|
|
2074
|
+
const visible = !field.visibleWhen || conditionMatchesValues(field.visibleWhen, currentValue);
|
|
2075
|
+
const applicable = Object.entries(field.requiredWhen)
|
|
2076
|
+
.filter(([conditionName]) => conditionName !== "status")
|
|
2077
|
+
.every(([conditionName, expected]) => conditionValueMatches(currentValue(conditionName), expected));
|
|
2078
|
+
group.hidden = !visible || !applicable;
|
|
2079
|
+
const required = visible && applicable && conditionMatchesValues(field.requiredWhen, currentValue);
|
|
1857
2080
|
refreshGroup(group, required);
|
|
1858
2081
|
}
|
|
1859
2082
|
for (const group of dialog.querySelectorAll('[data-kind="relation-array"][data-required="true"]')) {
|
|
1860
2083
|
refreshGroup(group, true);
|
|
1861
2084
|
}
|
|
1862
|
-
for (const
|
|
2085
|
+
for (const markdown of markdownDefinitions) {
|
|
2086
|
+
if (!markdown.requiredWhen) continue;
|
|
2087
|
+
const required = conditionMatchesValues(markdown.requiredWhen, currentValue);
|
|
2088
|
+
const wrapper = dialog.querySelector('[data-content-editor="' + CSS.escape(markdown.name) + '"]');
|
|
2089
|
+
const control = wrapper?.querySelector("textarea");
|
|
2090
|
+
const mark = wrapper?.querySelector(".required-mark");
|
|
2091
|
+
if (control) control.required = required;
|
|
2092
|
+
if (mark) mark.hidden = !required;
|
|
2093
|
+
}
|
|
2094
|
+
for (const definition of oneOfGroups) {
|
|
2095
|
+
const names = definition.fields || [];
|
|
2096
|
+
const active = !definition.when || conditionMatchesValues(definition.when, currentValue);
|
|
1863
2097
|
const choices = names.map((name) => {
|
|
1864
2098
|
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
1865
2099
|
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
@@ -1871,6 +2105,15 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
|
1871
2105
|
control.required = false;
|
|
1872
2106
|
control.setCustomValidity("");
|
|
1873
2107
|
});
|
|
2108
|
+
for (const name of names) {
|
|
2109
|
+
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
2110
|
+
const wrapper = markdownName
|
|
2111
|
+
? dialog.querySelector('[data-content-editor="' + CSS.escape(markdownName) + '"]')
|
|
2112
|
+
: dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
2113
|
+
const mark = wrapper?.querySelector(".required-mark");
|
|
2114
|
+
if (mark) mark.hidden = !active;
|
|
2115
|
+
}
|
|
2116
|
+
if (!active) continue;
|
|
1874
2117
|
const choice = choices.find(({ present }) => present) || choices[0];
|
|
1875
2118
|
if (choice) {
|
|
1876
2119
|
choice.control.required = true;
|
|
@@ -1941,11 +2184,28 @@ function openContentEditor(entry, name) {
|
|
|
1941
2184
|
}
|
|
1942
2185
|
|
|
1943
2186
|
function bindCommon() {
|
|
1944
|
-
root.querySelectorAll("
|
|
2187
|
+
root.querySelectorAll("[data-stage-page-completion]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
2188
|
+
event.preventDefault();
|
|
2189
|
+
event.stopPropagation();
|
|
2190
|
+
const label = button.textContent;
|
|
2191
|
+
button.disabled = true;
|
|
2192
|
+
button.textContent = "Saving…";
|
|
2193
|
+
try {
|
|
2194
|
+
await toggleStagePageCompletion(button);
|
|
2195
|
+
render();
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
button.disabled = false;
|
|
2198
|
+
button.textContent = label;
|
|
2199
|
+
button.title = error.message;
|
|
2200
|
+
}
|
|
2201
|
+
}));
|
|
2202
|
+
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
1945
2203
|
const group = button.closest(".nav-group");
|
|
1946
2204
|
const open = group.classList.toggle("open");
|
|
1947
2205
|
setNavigationGroupOpen(group.dataset.group, open);
|
|
1948
2206
|
button.setAttribute("aria-expanded", String(open));
|
|
2207
|
+
const label = button.closest(".nav-heading-row, .nav-subheading-row")?.querySelector(".nav-heading, .nav-subheading")?.textContent.trim() || "group";
|
|
2208
|
+
button.setAttribute("aria-label", (open ? "Collapse " : "Expand ") + label);
|
|
1949
2209
|
}));
|
|
1950
2210
|
const navButton = root.querySelector(".mobile-nav");
|
|
1951
2211
|
const sidebar = root.querySelector(".sidebar");
|
|
@@ -1967,6 +2227,15 @@ function bindCommon() {
|
|
|
1967
2227
|
navButton?.focus();
|
|
1968
2228
|
});
|
|
1969
2229
|
const search = root.querySelector("#global-search");
|
|
2230
|
+
const sidebarNavigation = root.querySelector(".sidebar-nav");
|
|
2231
|
+
sidebarNavigation?.addEventListener("scroll", () => {
|
|
2232
|
+
navigationScrollTop = sidebarNavigation.scrollTop;
|
|
2233
|
+
try {
|
|
2234
|
+
window.localStorage.setItem(NAV_SCROLL_STORAGE_KEY, String(navigationScrollTop));
|
|
2235
|
+
} catch {
|
|
2236
|
+
// Browser storage may be unavailable; navigation still preserves the position in memory.
|
|
2237
|
+
}
|
|
2238
|
+
}, { passive: true });
|
|
1970
2239
|
search?.addEventListener("keydown", (event) => {
|
|
1971
2240
|
if (event.key === "Enter" && search.value.trim()) {
|
|
1972
2241
|
event.preventDefault();
|
|
@@ -2074,6 +2343,28 @@ function formatCadence(value) {
|
|
|
2074
2343
|
return value.mode ? humanize(value.mode) : "";
|
|
2075
2344
|
}
|
|
2076
2345
|
function resourcesOfType(type) { return state.resources.filter(({ record }) => record.type === type); }
|
|
2346
|
+
function currentPeopleForParties(ids = [], seen = new Set()) {
|
|
2347
|
+
const people = [];
|
|
2348
|
+
for (const id of ids) {
|
|
2349
|
+
if (seen.has(id)) continue;
|
|
2350
|
+
seen.add(id);
|
|
2351
|
+
const party = state.resources.find(({ record }) => record.id === id)?.record;
|
|
2352
|
+
if (party?.type === "person" && ["active", "external"].includes(party.status)) people.push(party.id);
|
|
2353
|
+
if (party?.type === "team" && party.status === "active") {
|
|
2354
|
+
people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
return [...new Set(people)];
|
|
2358
|
+
}
|
|
2359
|
+
function completionTeam(item) {
|
|
2360
|
+
const ownerIds = new Set(item.ownerIds || []);
|
|
2361
|
+
const teams = resourcesOfType("team").map(({ record }) => record);
|
|
2362
|
+
const ownedTeams = teams.filter((record) => ownerIds.has(record.id));
|
|
2363
|
+
return (ownedTeams.length ? ownedTeams : teams).find((record) => (
|
|
2364
|
+
record.status === "active"
|
|
2365
|
+
&& currentPeopleForParties(record.chairIds || []).length
|
|
2366
|
+
)) || null;
|
|
2367
|
+
}
|
|
2077
2368
|
function searchText(record) {
|
|
2078
2369
|
const definition = state.model.resources[record.type];
|
|
2079
2370
|
if (!definition) return "";
|
|
@@ -2097,6 +2388,14 @@ function readNavigationGroupState() {
|
|
|
2097
2388
|
return {};
|
|
2098
2389
|
}
|
|
2099
2390
|
}
|
|
2391
|
+
function readNavigationScrollTop() {
|
|
2392
|
+
try {
|
|
2393
|
+
const value = Number(window.localStorage.getItem(NAV_SCROLL_STORAGE_KEY));
|
|
2394
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
2395
|
+
} catch {
|
|
2396
|
+
return 0;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2100
2399
|
function setNavigationGroupOpen(group, open) {
|
|
2101
2400
|
navigationGroupState[group] = open;
|
|
2102
2401
|
try {
|
|
@@ -2108,6 +2407,9 @@ function setNavigationGroupOpen(group, open) {
|
|
|
2108
2407
|
function groupTitle(id) { return state.model.groups.find((group) => group.id === id)?.title || "Program"; }
|
|
2109
2408
|
function fieldDefinition(type, name) { return state.model.resources[type]?.fields?.[name] || state.model.commonFields[name]; }
|
|
2110
2409
|
function fieldLabel(type, name) {
|
|
2410
|
+
if (name === "$operationTracking") return "Operation tracking";
|
|
2411
|
+
if (name === "$workQueueStatus") return "Work Queue";
|
|
2412
|
+
if (type === "obligation" && name === "status") return "Configuration";
|
|
2111
2413
|
if (name === "title") return state.model.resources[type]?.titleLabel || state.model.commonFields.title.label;
|
|
2112
2414
|
const recordContent = recordContentDefinition(type);
|
|
2113
2415
|
if (recordContent && name === recordContent.slot) return recordContent.label;
|
|
@@ -2127,13 +2429,32 @@ function titleCase(value) {
|
|
|
2127
2429
|
return word.toLowerCase().replace(/(^|[-/])([a-z])/g, (_, boundary, letter) => boundary + letter.toUpperCase());
|
|
2128
2430
|
}).join(" ");
|
|
2129
2431
|
}
|
|
2130
|
-
function conditionMatches(record, condition) {
|
|
2432
|
+
function conditionMatches(record, condition) {
|
|
2433
|
+
return Boolean(condition) && Object.entries(condition).every(([name, expected]) => (
|
|
2434
|
+
Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
|
|
2435
|
+
));
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function conditionMatchesValues(condition, valueFor) {
|
|
2439
|
+
return Boolean(condition) && Object.entries(condition).every(([name, expected]) => {
|
|
2440
|
+
const actual = valueFor(name);
|
|
2441
|
+
return conditionValueMatches(actual, expected);
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function conditionValueMatches(actual, expected) {
|
|
2446
|
+
return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
|
|
2447
|
+
}
|
|
2131
2448
|
function formatValue(value, field, type) {
|
|
2132
2449
|
if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
|
|
2133
2450
|
const definition = fieldDefinition(type, field);
|
|
2134
2451
|
if (definition?.type === "date") return esc(formatCalendarDate(value));
|
|
2135
2452
|
if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
|
|
2136
|
-
if (
|
|
2453
|
+
if (type === "obligation" && field === "status") {
|
|
2454
|
+
const label = value === "active" ? "Enabled" : properCase(value);
|
|
2455
|
+
return '<span class="badge ' + (value === "active" ? "neutral" : "status-" + esc(String(value))) + '">' + esc(label) + '</span>';
|
|
2456
|
+
}
|
|
2457
|
+
if (field === "status" || field.endsWith("Rating") || field === "severity" || field === "outcome") return '<span class="badge status-' + esc(String(value)) + '">' + esc(properCase(value)) + '</span>';
|
|
2137
2458
|
if (Array.isArray(value)) return value.length ? value.map((item) => typeof item === "object" ? '<code>' + esc(JSON.stringify(item)) + '</code>' : formatReference(item)).join(" ") : '<span class="muted">None</span>';
|
|
2138
2459
|
if (field === "sourceReference" && typeof value === "object") {
|
|
2139
2460
|
const href = safeExternalUrl(value.url);
|
|
@@ -2143,8 +2464,64 @@ function formatValue(value, field, type) {
|
|
|
2143
2464
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
2144
2465
|
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2145
2466
|
if (reference) return '<a class="tag relation" href="#/resource/' + encodeURIComponent(reference.record.type) + '/' + encodeURIComponent(reference.record.id) + '">' + esc(reference.record.title) + '</a>';
|
|
2467
|
+
if (definition?.type === "enum") return esc(properCase(value));
|
|
2146
2468
|
return esc(String(value));
|
|
2147
2469
|
}
|
|
2470
|
+
function controlOperationTracking(control) {
|
|
2471
|
+
const obligations = resourcesOfType("obligation")
|
|
2472
|
+
.map(({ record }) => record)
|
|
2473
|
+
.filter((record) => record.status !== "retired" && (record.controlIds || []).includes(control.id));
|
|
2474
|
+
if (!obligations.length) {
|
|
2475
|
+
return '<span class="operation-tracking evidence"><strong>Not scheduled in Work Queue</strong><small>Show operation with evidence records</small></span>';
|
|
2476
|
+
}
|
|
2477
|
+
const counts = obligations.reduce((result, obligation) => {
|
|
2478
|
+
const status = workQueueScheduleStatus(obligation, control);
|
|
2479
|
+
result[status] = (result[status] || 0) + 1;
|
|
2480
|
+
return result;
|
|
2481
|
+
}, {});
|
|
2482
|
+
const scheduleCount = obligations.length + " " + pluralize("schedule", obligations.length);
|
|
2483
|
+
if (counts.running === obligations.length) {
|
|
2484
|
+
return '<a class="operation-tracking running" href="#/stage/run"><strong>Running in Work Queue</strong><small>' + esc(scheduleCount) + '</small></a>';
|
|
2485
|
+
}
|
|
2486
|
+
if (counts["waiting-policy"] === obligations.length) {
|
|
2487
|
+
return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Waiting for policy approval</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
|
|
2488
|
+
}
|
|
2489
|
+
if (counts["waiting-control"] === obligations.length) {
|
|
2490
|
+
return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Ready when implemented</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
|
|
2491
|
+
}
|
|
2492
|
+
if (counts.paused === obligations.length) {
|
|
2493
|
+
return '<a class="operation-tracking paused" href="#/stage/run"><strong>Work Queue paused</strong><small>' + esc(scheduleCount) + '</small></a>';
|
|
2494
|
+
}
|
|
2495
|
+
const summary = [
|
|
2496
|
+
counts.running ? counts.running + " running" : "",
|
|
2497
|
+
counts["waiting-policy"] ? counts["waiting-policy"] + " waiting for policy" : "",
|
|
2498
|
+
counts["waiting-control"] ? counts["waiting-control"] + " waiting for implementation" : "",
|
|
2499
|
+
counts.paused ? counts.paused + " paused" : ""
|
|
2500
|
+
].filter(Boolean).join(" · ");
|
|
2501
|
+
return '<a class="operation-tracking mixed" href="#/stage/run"><strong>Work Queue needs attention</strong><small>' + esc(summary) + '</small></a>';
|
|
2502
|
+
}
|
|
2503
|
+
function workQueueScheduleStatus(obligation, control = null) {
|
|
2504
|
+
if (obligation.status === "paused") return "paused";
|
|
2505
|
+
if (obligation.status !== "active") return obligation.status;
|
|
2506
|
+
const asOf = state.obligations?.asOf || new Date().toISOString().slice(0, 10);
|
|
2507
|
+
const policies = (obligation.policyIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
|
|
2508
|
+
if (!policies.every((policy) => policy?.type === "policy" && policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf)) {
|
|
2509
|
+
return "waiting-policy";
|
|
2510
|
+
}
|
|
2511
|
+
const linkedControls = (obligation.controlIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
|
|
2512
|
+
const implemented = control
|
|
2513
|
+
? control.status === "implemented"
|
|
2514
|
+
: !linkedControls.length || linkedControls.some((record) => record?.type === "control" && record.status === "implemented");
|
|
2515
|
+
return implemented ? "running" : "waiting-control";
|
|
2516
|
+
}
|
|
2517
|
+
function obligationWorkQueueStatus(obligation) {
|
|
2518
|
+
const status = workQueueScheduleStatus(obligation);
|
|
2519
|
+
if (status === "running") return '<span class="operation-tracking running"><strong>Running</strong><small>Enabled and policy effective</small></span>';
|
|
2520
|
+
if (status === "waiting-policy") return '<span class="operation-tracking waiting"><strong>Waiting for policy</strong><small>Schedule enabled</small></span>';
|
|
2521
|
+
if (status === "waiting-control") return '<span class="operation-tracking waiting"><strong>Waiting for control</strong><small>Implement a linked control</small></span>';
|
|
2522
|
+
if (status === "paused") return '<span class="operation-tracking paused"><strong>Paused</strong><small>Schedule disabled</small></span>';
|
|
2523
|
+
return '<span class="operation-tracking"><strong>' + esc(properCase(status)) + '</strong></span>';
|
|
2524
|
+
}
|
|
2148
2525
|
function formatReference(value) {
|
|
2149
2526
|
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2150
2527
|
return reference ? '<a class="tag relation" href="#/resource/' + encodeURIComponent(reference.record.type) + '/' + encodeURIComponent(reference.record.id) + '">' + esc(reference.record.title) + '</a>' : '<span class="tag">' + esc(value) + '</span>';
|
|
@@ -2188,7 +2565,7 @@ async function localFetch(url, options) {
|
|
|
2188
2565
|
try {
|
|
2189
2566
|
return await fetch(url, options);
|
|
2190
2567
|
} catch {
|
|
2191
|
-
throw new Error("The
|
|
2568
|
+
throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
|
|
2192
2569
|
}
|
|
2193
2570
|
}
|
|
2194
2571
|
async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
|
|
@@ -2197,34 +2574,40 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
|
|
|
2197
2574
|
|
|
2198
2575
|
export const APP_STYLES = String.raw`
|
|
2199
2576
|
:root{--ink:#151827;--muted:#5d6475;--line:#dfe3ef;--paper:#f6f7fb;--panel:#fff;--accent:#0000a5;--accent-soft:#eef1ff;--accent-light:#8aa1ff;--focus:#0000e0;--amber:#8a5200;--red:#a13a31;--sidebar:linear-gradient(135deg,#000070 0%,#000035 60%);--primary-gradient:linear-gradient(135deg,#000070 0%,#000035 60%);--surface-soft:#f2f4fa;--surface-muted:#eceff7;--field:#fff;--field-readonly:#eef0f6;--code-bg:#10162b;--code-ink:#e8ebff;--shadow:0 8px 28px rgba(0,0,53,.08);color-scheme:light dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
|
2200
|
-
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:15px}.brand small{font-size:11px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:13px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:10px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:10px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:11px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:17px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:9px;margin:0}.search{height:39px;max-width:480px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:13px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:10px}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:11px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:28px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:13px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:25px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:10px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:14px;margin:4px 0 0}.panel-head>a{font-size:11px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:30px;font-weight:500;display:block}.progress-number span{font-size:10px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:10px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:11px}.due-list small{font-size:9px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:10px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:10px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:31px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:13px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:12px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:12px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:10px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:11px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:9px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:9px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:9px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:11px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:10px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:9px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:29px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:23px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:15px;margin-top:1.7em}.markdown p,.markdown li{font-size:13px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:11px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:11px;min-width:0}.compact-json{white-space:pre-wrap;font-size:9px}.git-panel>code{font-size:9px;word-break:break-all}.git-panel p{font-size:10px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:9px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:10px}.history small{font-size:9px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:11px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:11px;overflow-wrap:anywhere}.diagnostics code{font-size:9px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:22px;cursor:pointer}.editor form>p{font-size:11px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:11px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
2201
|
-
.topbar-status{display:flex;align-items:center;gap:8px}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:
|
|
2202
|
-
.audit-progress-empty{padding:11px 13px;border-radius:8px;background:var(--surface-soft)}.audit-progress-empty strong,.audit-progress-empty span{display:block}.audit-progress-empty strong{font-size:
|
|
2577
|
+
*{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:18px}.brand small{font-size:13.2px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:15.6px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:12px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:12px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:13.2px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:20.4px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:10.8px;margin:0}.search{height:39px;max-width:480px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:15.6px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:12px}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:33.6px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:15.6px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:12px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:16.8px;margin:4px 0 0}.panel-head>a{font-size:13.2px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:36px;font-weight:500;display:block}.progress-number span{font-size:12px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:10.8px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:12px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:13.2px}.due-list small{font-size:10.8px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:12px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:12px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:15.6px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:14.4px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:14.4px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:12px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:13.2px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:10.8px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:10.8px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:10.8px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:13.2px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:10.8px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:34.8px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:27.6px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:18px;margin-top:1.7em}.markdown p,.markdown li{font-size:15.6px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:13.2px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:13.2px;min-width:0}.compact-json{white-space:pre-wrap;font-size:10.8px}.git-panel>code{font-size:10.8px;word-break:break-all}.git-panel p{font-size:12px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:10.8px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:12px}.history small{font-size:10.8px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:13.2px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:13.2px;overflow-wrap:anywhere}.diagnostics code{font-size:10.8px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:26.4px;cursor:pointer}.editor form>p{font-size:13.2px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:13.2px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
2578
|
+
.topbar-status{display:flex;align-items:center;gap:8px}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
|
|
2579
|
+
.audit-progress-empty{padding:11px 13px;border-radius:8px;background:var(--surface-soft)}.audit-progress-empty strong,.audit-progress-empty span{display:block}.audit-progress-empty strong{font-size:13.2px}.audit-progress-empty span{margin-top:4px;color:var(--muted);font-size:10.8px}
|
|
2203
2580
|
.icon-button{position:relative;display:grid;place-items:center;padding:0;color:var(--ink);font-size:0}.icon-button:before,.icon-button:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.icon-button:after{transform:rotate(-45deg)}
|
|
2204
2581
|
html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(0,1fr);height:100vh;min-height:0}.sidebar{display:flex;flex-direction:column;height:100vh;overflow:hidden;overscroll-behavior:contain}.workspace{height:100vh;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
|
|
2205
2582
|
.topbar{height:63px}
|
|
2206
|
-
.
|
|
2207
|
-
.nav-stage>.nav-items
|
|
2583
|
+
.sidebar-nav{scrollbar-width:none;-ms-overflow-style:none}.sidebar-nav::-webkit-scrollbar{display:none}
|
|
2584
|
+
.brand{flex:0 0 auto;margin-bottom:18px}.sidebar-nav{--nav-control-width:14px;flex:1;min-height:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;padding-right:1px}.nav-home{margin-bottom:10px}.nav-stage>.nav-heading{display:grid;grid-template-columns:24px minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;padding:7px 6px;border-radius:7px;color:#d5d9ed;text-align:left;text-transform:none;letter-spacing:0}.nav-stage>.nav-heading:hover{background:rgba(255,255,255,.08);color:#fff}.nav-stage-number{display:grid;place-items:center;width:22px;height:22px;border:1px solid rgba(255,255,255,.26);border-radius:50%;font-size:9.6px}.nav-stage-copy,.nav-stage-copy strong,.nav-stage-copy small{display:block;min-width:0}.nav-stage-copy strong{font-size:12px;line-height:1.25}.nav-stage-copy small{margin-top:2px;color:#aeb6d8;font-size:9.6px;line-height:1.25;font-weight:500}.nav-stage>.nav-items{margin:2px 0 8px 17px;padding:1px 0 5px 12px;border-left:1px solid rgba(255,255,255,.14)}.nav-subgroup>.nav-subheading,.nav-subgroup>.nav-items a{width:100%;display:grid;grid-template-columns:minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;padding-right:6px}.nav-subgroup>.nav-subheading{border:0;background:none;padding-top:7px;padding-bottom:4px;padding-left:7px;color:#919bc4;text-align:left;text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;cursor:pointer}.nav-control,.nav-control-slot{justify-self:end;width:var(--nav-control-width);text-align:right}.nav-subgroup>.nav-items{padding:1px 0 4px 3px}.nav-subgroup>.nav-items a{padding-top:6px;padding-bottom:6px;padding-left:8px;font-size:13.2px}.nav-group.open>.nav-heading>.chevron:before,.nav-group.open>.nav-subheading>.chevron:before{transform:rotate(45deg)}.nav-stage.open>.nav-items>.nav-subgroup:not(.open)>.nav-items{display:none}.sidebar-footer{flex:0 0 auto;margin:10px -18px -18px;padding:10px 18px 14px;background:#000024;border-top:1px solid rgba(255,255,255,.14)}.organization-nav{display:grid;grid-template-columns:32px minmax(0,1fr) 12px;gap:9px;align-items:center;padding:8px;border-radius:8px;color:#eef1ff;text-decoration:none}.organization-nav:hover,.organization-nav.current{background:rgba(255,255,255,.11)}.organization-mark{display:grid;place-items:center;width:32px;height:32px;border:1px solid rgba(255,255,255,.25);border-radius:50%;background:rgba(255,255,255,.08);font-size:13.2px;font-weight:800}.organization-nav strong,.organization-nav small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organization-nav strong{font-size:12px}.organization-nav small{margin-top:2px;color:#aeb6d8;font-size:9.6px}.organization-arrow{color:#aeb6d8;font-size:19.2px}.organization-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.organization-links{display:grid}.organization-links a{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-top:1px solid var(--line);text-decoration:none}.organization-links a:first-child{padding-top:0;border-top:0}.organization-links strong,.organization-links small{display:block}.organization-links strong{font-size:13.2px}.organization-links small{margin-top:3px;color:var(--muted);font-size:10.8px;line-height:1.4}.organization-links b{color:var(--accent);font-size:13.2px}.organization-links a:hover strong{color:var(--accent)}
|
|
2585
|
+
.nav-stage>.nav-items>a.nav-direct{display:grid;grid-template-columns:minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;width:100%;padding:6px 6px 6px 7px;font-size:13.2px}
|
|
2586
|
+
.nav-heading-row{display:grid;grid-template-columns:minmax(0,1fr) 24px;gap:2px;align-items:stretch}.nav-heading-row>.nav-heading{display:grid;grid-template-columns:24px minmax(0,1fr);gap:6px;align-items:center;width:100%;padding:7px 6px;border-radius:7px;color:#d5d9ed;text-align:left;text-transform:none;letter-spacing:0;text-decoration:none}.nav-heading-row>.nav-heading:hover,.nav-heading-row>.nav-heading.current{background:rgba(255,255,255,.1);color:#fff}.nav-toggle{display:grid;place-items:center;width:24px;height:auto;min-height:100%;padding:0;border:0;border-radius:6px;background:none;color:#aeb6d8;cursor:pointer}.nav-toggle:hover{background:rgba(255,255,255,.1);color:#fff}.nav-chevron{display:block;width:12px;height:12px;place-self:center;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}.nav-subheading-row{display:grid;grid-template-columns:minmax(0,1fr) 22px;gap:2px;align-items:center;width:100%;padding:0;border:0;border-radius:6px;background:none;color:#919bc4;cursor:pointer}.nav-subheading-row:hover{background:rgba(255,255,255,.1);color:#fff}.nav-subheading-row>.nav-subheading{display:flex;align-items:center;min-width:0;padding:7px;color:inherit;text-align:left;text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780}.nav-group.open>.nav-heading-row .nav-chevron,.nav-group.open>.nav-subheading-row .nav-chevron{transform:rotate(90deg)}
|
|
2587
|
+
.stage-overview-hero{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:28px;align-items:center;padding:26px 28px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stage-overview-hero h2,.group-overview-head h2{font:500 37.2px Georgia,serif;margin:7px 0}.stage-overview-hero>div>p:not(.kicker),.group-overview-head>div>p:not(.kicker){max-width:760px;color:var(--muted);font-size:14.4px;line-height:1.55;margin:0}.stage-progress-card{padding:15px 17px;background:var(--paper);border:1px solid var(--line);border-radius:9px}.stage-progress-card>div:first-child{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}.stage-progress-card>div:first-child>strong{font:500 33.6px Georgia,serif}.stage-progress-card p{color:var(--muted);font-size:10.8px;line-height:1.45;margin:9px 0 0}.badge.neutral{background:#e5e8f2;color:#555e73}.stage-overview-layout{display:grid;grid-template-columns:320px minmax(0,1fr);gap:15px;margin-top:15px;align-items:start}.stage-plan ol,.group-plan ol{display:grid;gap:12px;padding-left:20px;margin:0}.stage-plan li,.group-plan li{padding-left:4px;color:var(--ink);font-size:13.2px;line-height:1.5}.stage-groups{min-width:0}.stage-groups>.section-head{margin:4px 0 13px}.stage-group-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-group-card{position:relative;display:block;min-height:138px;padding:18px 38px 17px 18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.stage-group-card:hover{border-color:var(--accent-light)}.stage-group-card h3{font-size:15.6px;margin:0 0 7px}.stage-group-card p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.stage-group-card small{display:block;color:var(--accent);font-size:9.6px;font-weight:700;margin-top:12px}.stage-group-arrow{position:absolute;right:16px;top:16px;color:var(--accent);font-size:24px}.group-overview-head{display:flex;justify-content:space-between;align-items:end;gap:25px;margin-bottom:15px}.stage-status-link{display:grid;grid-template-columns:auto auto;align-items:center;gap:4px 12px;min-width:155px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);border-radius:9px;text-decoration:none}.stage-status-link>strong{font:500 28.8px Georgia,serif;text-align:right}.stage-status-link>small{grid-column:1/-1;color:var(--muted);font-size:9.6px;text-align:right}.relationship-note{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;align-items:center;margin-bottom:15px;padding:17px 20px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.relationship-note h3{font-size:15.6px;margin:5px 0 0}.relationship-note>p{color:var(--muted);font-size:12px;line-height:1.55;margin:0}.relationship-note code{font-size:10.8px}.group-plan{margin-bottom:24px}.group-related-links{display:flex;align-items:center;gap:8px;margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}.group-related-links>span{color:var(--muted);font-size:10.8px;margin-right:auto}.group-destinations>.section-head{margin-bottom:12px}.group-destination-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.group-destination-card{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:16px;min-height:150px;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.group-destination-card:hover{border-color:var(--accent-light)}.group-destination-card h3{font-size:15.6px;margin:5px 0 7px}.group-destination-card p:not(.kicker){color:var(--muted);font-size:10.8px;line-height:1.5;margin:0}.destination-rollup{align-self:center;text-align:right}.destination-rollup strong,.destination-rollup small{display:block}.destination-rollup strong{font:500 32.4px Georgia,serif}.destination-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:3px}
|
|
2588
|
+
.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion{position:relative;z-index:2;color:var(--accent)}.stage-page-completion.complete{color:#176143;border-color:#b9dac6;background:#edf7f1}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
|
|
2208
2589
|
.button{text-decoration:none}
|
|
2209
|
-
.home-page{padding-top:16px;padding-bottom:16px}.overview-hero{min-height:72px;padding:10px 20px;align-items:center}.overview-hero h2{font-size:
|
|
2590
|
+
.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}
|
|
2210
2591
|
.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}
|
|
2211
|
-
.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:
|
|
2212
|
-
.nav-close,.nav-scrim{display:none}.pagination{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px}.pagination[hidden]{display:none}.page-status{color:var(--muted);font-size:
|
|
2592
|
+
.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}
|
|
2593
|
+
.nav-close,.nav-scrim{display:none}.pagination{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:14px}.pagination[hidden]{display:none}.page-status{color:var(--muted);font-size:12px;min-width:150px;text-align:center}.button:disabled{cursor:not-allowed;opacity:.45}.search-pagination{padding-top:2px}
|
|
2213
2594
|
.list-tools{flex-wrap:wrap}.list-tools label{min-width:220px}.list-header-tools{flex:1;justify-content:flex-end;margin:0 0 0 28px}.list-header-tools label{flex:1 1 220px;max-width:360px}.list-header-tools select{max-width:190px}.list-header-tools .button{white-space:nowrap}
|
|
2214
|
-
.setup-banner{margin:14px 0;background:#eef1ff;border:1px solid #ccd4ff;border-radius:11px;padding:19px 22px;display:grid;grid-template-columns:1fr 1.3fr;gap:25px;align-items:center}.setup-banner h3{margin:5px 0 6px;font-size:
|
|
2215
|
-
.record-content-action{display:flex;justify-content:flex-start;margin-top:20px}.record-content-details{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.record-content-details summary{cursor:pointer;color:var(--accent);font-size:
|
|
2216
|
-
.program-setup{grid-template-columns:minmax(250px,.75fr) minmax(440px,1.4fr)}.setup-steps{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.setup-steps a{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;align-items:start;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);text-decoration:none}.setup-steps a:hover{border-color:var(--accent-light)}.setup-steps a>span:first-child{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--accent-soft);color:var(--accent);font-size:
|
|
2217
|
-
.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
|
|
2218
|
-
.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:
|
|
2219
|
-
.
|
|
2220
|
-
.page-
|
|
2595
|
+
.setup-banner{margin:14px 0;background:#eef1ff;border:1px solid #ccd4ff;border-radius:11px;padding:19px 22px;display:grid;grid-template-columns:1fr 1.3fr;gap:25px;align-items:center}.setup-banner h3{margin:5px 0 6px;font-size:18px}.setup-banner p:not(.kicker){margin:0;color:var(--muted);font-size:13.2px;line-height:1.5}.setup-banner ol{margin:0;padding-left:22px;display:grid;gap:7px}.setup-banner li{font-size:13.2px}.setup-banner a{color:var(--accent);font-weight:700}.due-list time.overdue{color:var(--red)}.content-label{display:flex;align-items:center;justify-content:space-between;gap:12px}.text-button{border:0;background:none;color:var(--accent);font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;font-weight:750;cursor:pointer;white-space:nowrap}.tag{white-space:normal;overflow-wrap:anywhere;max-width:100%}.editor{max-height:calc(100vh - 30px);overflow:auto}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:20px 0}.form-field>.field-label,.content-editor-field>span{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#3e4557;font-size:12px;font-weight:700;margin-bottom:6px}.required-mark{font-size:9.6px;color:var(--accent);text-transform:uppercase;letter-spacing:.06em}.form-field input,.form-field select,.editor .form-field textarea{width:100%;height:auto;min-height:40px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);padding:9px 10px;font:14.4px/1.4 inherit}.editor .form-field textarea{height:82px}.form-field input[readonly]{background:#eef0f6;color:#5d6475}.form-field>small{display:block;color:#6a7181;font-size:10.8px;margin-top:5px}.checkbox-list{display:grid;gap:5px;max-height:145px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:7px}.checkbox-list label{display:flex;align-items:center;gap:8px;padding:5px;border-radius:5px}.checkbox-list input{width:16px;min-height:16px;padding:0;flex:0 0 auto}.checkbox-list label:hover{background:#f2f4fa}.checkbox-list span,.checkbox-list small{display:block;font-size:12px}.checkbox-list small{color:var(--muted);margin-top:2px}.missing-options{padding:11px;border:1px dashed #d7c8a9;background:#fbf5e9;color:#795b23;border-radius:7px;font-size:12px}.content-editor-field{display:block;margin:17px 0}.editor .content-editor-field textarea,.editor .markdown-source{height:260px;background:#10162b;color:#e8ebff;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.advanced-editor{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.advanced-editor summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.advanced-editor p{font-size:12px;color:var(--muted)}.editor .advanced-editor>textarea{height:320px}.alert-dialog{width:min(520px,calc(100vw - 30px));border:0;border-radius:12px;padding:23px;box-shadow:0 25px 80px rgba(0,0,24,.28)}.alert-dialog>p{font-size:14.4px;line-height:1.55;color:var(--muted)}.metadata dd{overflow-wrap:anywhere}
|
|
2596
|
+
.record-content-action{display:flex;justify-content:flex-start;margin-top:20px}.record-content-details{border-top:1px solid var(--line);margin-top:18px;padding-top:13px}.record-content-details summary{cursor:pointer;color:var(--accent);font-size:13.2px;font-weight:750}.record-content-details>p{color:var(--muted);font-size:12px}.record-content-editor>span small{color:var(--muted);font-size:10.8px;font-weight:500}
|
|
2597
|
+
.program-setup{grid-template-columns:minmax(250px,.75fr) minmax(440px,1.4fr)}.setup-steps{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.setup-steps a{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;align-items:start;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);text-decoration:none}.setup-steps a:hover{border-color:var(--accent-light)}.setup-steps a>span:first-child{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--accent-soft);color:var(--accent);font-size:12px}.setup-steps a.done>span:first-child{background:#dcefe4;color:#125733}.setup-steps strong,.setup-steps small{display:block}.setup-steps strong{font-size:12px}.setup-steps small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4;font-weight:500}
|
|
2598
|
+
.readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
|
|
2599
|
+
.page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
|
|
2600
|
+
.operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
|
|
2601
|
+
.page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:auto}.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;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-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{padding:12px 25px 23px}.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 0}.onboarding-dialog>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
|
|
2602
|
+
.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}
|
|
2221
2603
|
@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}}
|
|
2222
2604
|
@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))}}
|
|
2223
|
-
@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:
|
|
2224
|
-
@media(max-width:760px){.setup-banner,.page-guide{grid-template-columns:1fr}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.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:
|
|
2605
|
+
@media(max-width:760px){.shell{display:block}.sidebar{transform:translateX(-100%);transition:.2s;box-shadow:8px 0 30px rgba(0,0,0,.2)}.sidebar.shown{transform:translateX(0)}.workspace{min-width:0}.mobile-nav{display:block;border:0;background:none;font-size:24px}.topbar{height:72px;padding:0 16px}.topbar>div:first-of-type{min-width:0}.topbar-status{display:none}.search{display:flex;max-width:none}.search kbd,.topbar .eyebrow{display:none}.page{padding:20px 15px 60px}.hero{display:block;padding:23px}.hero-meta{margin-top:22px;flex-wrap:wrap}.metrics,.dashboard-grid,.organization-grid{grid-template-columns:1fr}.span-2{grid-column:auto}.catalog{grid-template-columns:repeat(2,1fr)}.detail-grid{grid-template-columns:1fr}.page-intro,.detail-head{display:block}.page-intro>.button,.actions{margin-top:15px}.page-intro>.list-header-tools{justify-content:flex-start;margin:15px 0 0}.list-header-tools label{max-width:none}.record-table{min-width:720px}.readiness-map{padding:17px}.readiness-map-head{grid-template-columns:1fr;gap:8px}.readiness-flow{grid-template-columns:repeat(2,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr}.audit-engagement .button{grid-column:auto}.resource-directory{grid-template-columns:1fr}}
|
|
2606
|
+
@media(max-width:760px){.setup-banner,.page-guide,.stage-overview-hero,.relationship-note,.group-destination-card,.stage-page-grid{grid-template-columns:1fr}.page-guide>div{border-left:0;border-top:1px solid var(--line)}.page-guide>div:first-child{border-top:0}.group-overview-head{display:block}.stage-progress-card,.stage-status-link{margin-top:15px}.destination-rollup{text-align:left}.form-grid{grid-template-columns:1fr}.record-table{min-width:0}.record-table thead{display:none}.record-table,.record-table tbody,.record-table tr{display:block}.record-table tr{padding:8px 12px;border-bottom:1px solid var(--line)}.record-table tr:last-child{border-bottom:0}.record-table td:not([data-label]){display:block}.record-table td[data-label]{display:grid;grid-template-columns:105px minmax(0,1fr);gap:10px;border:0;padding:7px 0;align-items:start}.record-table td[data-label]::before{content:attr(data-label);color:#75817b;text-transform:uppercase;letter-spacing:.07em;font-size:9.6px;font-weight:700}.record-table td[data-primary-field]{display:block;padding:8px 0 10px}.record-table td[data-primary-field]::before{display:none}.content-label{align-items:flex-start}.editor form{padding:18px}.diagnostics>div{grid-template-columns:58px minmax(0,1fr)}.diagnostics p{grid-column:1/-1}.changes code{overflow-wrap:anywhere}.onboarding-dialog{max-height:56vh}.onboarding-actions{position:sticky;bottom:0;background:var(--panel);border-top:1px solid var(--line)}}
|
|
2225
2607
|
@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}}
|
|
2226
2608
|
@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}}
|
|
2227
|
-
@media(
|
|
2609
|
+
@media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.detail-grid-structured aside>.panel{align-self:start}}
|
|
2610
|
+
@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}}
|
|
2228
2611
|
@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)}}
|
|
2229
2612
|
|
|
2230
2613
|
body,button,input,select,textarea,dialog{color:var(--ink)}
|
|
@@ -2286,25 +2669,27 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2286
2669
|
.record-table td[data-label]::before{color:var(--muted)}
|
|
2287
2670
|
.commit-dialog{width:min(560px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:var(--shadow);overflow:auto}
|
|
2288
2671
|
.commit-dialog form{padding:23px}
|
|
2289
|
-
.commit-dialog form>p{color:var(--muted);font-size:
|
|
2290
|
-
.commit-dialog label>span{display:block;font-size:
|
|
2291
|
-
.commit-dialog input{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:
|
|
2672
|
+
.commit-dialog form>p{color:var(--muted);font-size:13.2px;line-height:1.55}
|
|
2673
|
+
.commit-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}
|
|
2674
|
+
.commit-dialog input{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}
|
|
2292
2675
|
.commit-files{display:grid;gap:5px;max-height:160px;overflow:auto;margin-top:14px;padding:10px;background:var(--surface-soft);border-radius:7px}
|
|
2293
|
-
.commit-files code{font-size:
|
|
2676
|
+
.commit-files code{font-size:10.8px;overflow-wrap:anywhere}
|
|
2294
2677
|
.onboarding-progress{grid-template-columns:repeat(var(--onboarding-step-count),1fr)}
|
|
2295
|
-
.onboarding-git-status{display:flex;align-items:flex-start;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status .status-dot{margin-top:4px}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:
|
|
2296
|
-
.badge.status-overdue{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
|
|
2297
|
-
.obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:flex-start;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:
|
|
2298
|
-
.obligation-board{display:grid;grid-template-columns:repeat(
|
|
2299
|
-
.obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:
|
|
2300
|
-
.event-dialog label{display:block;margin-top:13px}.event-dialog label>span{display:block;font-size:
|
|
2301
|
-
.packet-builder form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:12px;align-items:end}.packet-builder label>span{display:block;font-size:
|
|
2302
|
-
.packet-preflight{display:grid;grid-template-columns:repeat(
|
|
2303
|
-
.audit-preparation{margin-bottom:12px}.audit-preparation .panel-head{align-items:flex-start}.audit-preparation .panel-head h3{margin:3px 0}.audit-preparation .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:
|
|
2304
|
-
@media(max-width:900px){.obligation-board
|
|
2678
|
+
.onboarding-git-status{display:flex;align-items:flex-start;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status .status-dot{margin-top:4px}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:12px}.onboarding-git-status small{color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:10.8px}
|
|
2679
|
+
.badge.status-overdue{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
|
|
2680
|
+
.obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:flex-start;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:12px}.obligation-preview small,.event-reminder-preview small{font-size:10.8px;color:var(--muted);margin-top:2px}.event-reminder-preview{grid-template-columns:repeat(2,minmax(0,1fr))}.event-reminder-preview a{padding:10px;border-radius:7px;background:var(--surface-soft);text-decoration:none}
|
|
2681
|
+
.obligation-board{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
|
|
2682
|
+
.obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px}
|
|
2683
|
+
.event-dialog label{display:block;margin-top:13px}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
2684
|
+
.packet-builder form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:12px;align-items:end}.packet-builder label>span{display:block;font-size:10.8px;font-weight:720;margin-bottom:6px}.packet-builder input,.packet-builder select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:13.2px}.packet-note,.packet-output>p{font-size:12px;color:var(--muted);margin:12px 0 0}.packet-output{margin:14px 0}.packet-output h3{overflow-wrap:anywhere}.packet-gaps{display:grid}.packet-gaps>div{display:grid;grid-template-columns:58px 1fr;gap:10px;border-top:1px solid var(--line);padding:10px 0}.packet-gaps>div:first-child{border-top:0}.packet-gaps p{font-size:12px;margin:0}.packet-list{display:grid}.packet-list a{display:block;text-decoration:none;border-top:1px solid var(--line);padding:9px 0}.packet-list a:first-child{border-top:0}.packet-list strong,.packet-list small{display:block}.packet-list strong{font-size:12px}.packet-list small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
2685
|
+
.packet-preflight{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-bottom:12px}.packet-preflight a{display:flex;align-items:flex-start;gap:9px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);text-decoration:none}.packet-preflight .status-dot{margin-top:4px}.packet-preflight small,.packet-preflight strong{display:block}.packet-preflight small{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.07em}.packet-preflight strong{margin-top:3px;font-size:12px}.audit-evidence-paths{margin-bottom:12px}.audit-evidence-paths .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.audit-evidence-path-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.audit-evidence-path-grid>a{display:block;padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.audit-evidence-path-grid h4{margin:7px 0 5px;font-size:13.2px}.audit-evidence-path-grid p{margin:0;color:var(--muted);font-size:10.8px;line-height:1.55}
|
|
2686
|
+
.audit-preparation{margin-bottom:12px}.audit-preparation .panel-head{align-items:flex-start}.audit-preparation .panel-head h3{margin:3px 0}.audit-preparation .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:10.8px}.preparation-progress{height:5px;margin:12px 0 0;border-radius:99px;background:var(--surface-muted);overflow:hidden}.preparation-progress span{display:block;height:100%;border-radius:inherit;background:var(--primary-gradient)}.audit-preparation-note{margin:9px 0 0;color:var(--muted);font-size:10.8px;line-height:1.5}.preparation-stages{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:12px}.preparation-stage{border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);overflow:hidden}.preparation-stage summary{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:11px 12px;cursor:pointer;list-style:none}.preparation-stage summary::-webkit-details-marker{display:none}.preparation-stage summary span,.preparation-stage summary strong,.preparation-stage summary small{display:block}.preparation-stage summary strong{font-size:12px}.preparation-stage summary small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.preparation-stage summary b{flex:none;color:var(--muted);font-size:9.6px;font-weight:650}.preparation-items{border-top:1px solid var(--line);background:var(--panel)}.preparation-items>a,.preparation-items>div{display:grid;grid-template-columns:22px minmax(0,1fr);gap:9px;padding:10px 12px;border-top:1px solid var(--line);text-decoration:none}.preparation-items>:first-child{border-top:0}.preparation-items strong,.preparation-items small{display:block}.preparation-items strong{font-size:10.8px}.preparation-items small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.45}.preparation-status{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--surface-muted);color:var(--muted);font-size:10.8px;font-weight:800}.preparation-status.complete{background:#dcefe4;color:#125733}.preparation-status.action{background:#f7dfdc;color:#873027}.preparation-status.later{background:#f6e8c9;color:#79500f}.preparation-status.external,.preparation-status.info{background:var(--accent-soft);color:var(--accent)}.audit-preparation-error:empty{display:none}
|
|
2687
|
+
@media(max-width:900px){.obligation-board{grid-template-columns:1fr}.policy-event-list{grid-template-columns:repeat(2,minmax(0,1fr))}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:auto;left:0}.policy-event-row:nth-child(2n) .policy-event-tooltip{right:0;left:auto}.packet-builder form,.packet-preflight{grid-template-columns:1fr 1fr}.packet-builder .button{align-self:end}}
|
|
2688
|
+
@media(max-width:1000px){.stage-overview-layout{grid-template-columns:1fr}.stage-page-grid,.group-destination-grid{grid-template-columns:1fr}}
|
|
2305
2689
|
@media(max-width:760px){.preparation-stages{grid-template-columns:1fr}}
|
|
2306
2690
|
@media(max-width:900px){.overview-grid{grid-template-columns:1fr}.overview-grid>.audit-panel{grid-column:auto}}
|
|
2307
|
-
@media(max-width:520px){.event-reminder-preview,.packet-builder form,.packet-preflight{grid-template-columns:1fr}.packet-metrics{grid-template-columns:1fr}.obligation-card-head{display:block}.obligation-card-head strong{display:block;text-align:left;margin-top:3px}}
|
|
2691
|
+
@media(max-width:520px){.event-reminder-preview,.policy-event-list,.packet-builder form,.packet-preflight,.audit-evidence-path-grid{grid-template-columns:1fr}.policy-event-row:nth-child(n) .policy-event-tooltip{right:auto;left:0}.packet-metrics{grid-template-columns:1fr}.obligation-card-head{display:block}.obligation-card-head strong{display:block;text-align:left;margin-top:3px}}
|
|
2692
|
+
@media(max-width:520px){.policy-event-feedback{grid-template-columns:auto minmax(0,1fr) auto}.policy-event-feedback>.button{grid-column:2}.policy-event-feedback>.icon-button{grid-column:3;grid-row:1}}
|
|
2308
2693
|
|
|
2309
2694
|
@media(prefers-color-scheme:dark){
|
|
2310
2695
|
:root{--ink:#f4f5ff;--muted:#b8bfd3;--line:#343d5c;--paper:#000;--panel:#141a2e;--accent:#aab7ff;--accent-soft:#252e52;--accent-light:#9aabff;--focus:#bdc7ff;--amber:#ffd08a;--red:#ffaaa0;--surface-soft:#1b2238;--surface-muted:#252d48;--field:#11172a;--field-readonly:#1c2338;--shadow:0 12px 34px rgba(0,0,0,.3)}
|
|
@@ -2312,12 +2697,14 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2312
2697
|
.badge.good{background:#173b2b;color:#a8edc4}
|
|
2313
2698
|
.badge.warn{background:#483714;color:#ffd991}
|
|
2314
2699
|
.badge.bad{background:#4a252a;color:#ffb5ad}
|
|
2700
|
+
.badge.neutral{background:#252d48;color:#c8cff0}
|
|
2315
2701
|
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#173b2b;color:#a8edc4}
|
|
2316
2702
|
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#4a252a;color:#ffb5ad}
|
|
2317
2703
|
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991}
|
|
2318
2704
|
.badge.status-overdue{background:#4a252a;color:#ffb5ad}
|
|
2319
2705
|
.badge.status-due{background:#483714;color:#ffd991}
|
|
2320
2706
|
.badge.status-complete{background:#173b2b;color:#a8edc4}
|
|
2707
|
+
.policy-event-feedback{border-color:#315f48;background:#183426}.policy-event-feedback p{color:#b7d8c3}
|
|
2321
2708
|
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
2322
2709
|
.status-dot.neutral{background:#9aabff}
|
|
2323
2710
|
}
|