filegrc 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/model/v1.json +159 -165
- package/package.json +1 -1
- package/src/agent.js +4 -0
- package/src/audit-preparation.js +217 -116
- package/src/cli.js +264 -7
- package/src/evidence-packet.js +72 -19
- package/src/evidence-tests.js +69 -0
- package/src/git.js +1 -0
- package/src/index.js +11 -0
- package/src/model-docs.js +51 -6
- package/src/obligations.js +87 -11
- package/src/program-lifecycle.js +22 -0
- package/src/program-path.js +275 -0
- package/src/program-readiness.js +635 -0
- package/src/resource-markdown.js +11 -4
- package/src/server.js +8 -0
- package/src/setup.js +187 -0
- package/src/state.js +8 -1
- package/src/validate.js +67 -5
- package/src/web.js +757 -428
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) {
|
|
@@ -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
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,12 +206,14 @@ 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
219
|
: state.model.resources[route.type]?.pluralTitle || "FileGRC";
|
|
@@ -253,64 +222,265 @@ function topbar(route) {
|
|
|
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 obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
|
|
231
|
+
const setupBanner = setupPending
|
|
232
|
+
? '<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>'
|
|
233
|
+
: "";
|
|
234
|
+
const auditPanel = program.evidenceReady
|
|
235
|
+
? '<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>' +
|
|
236
|
+
(activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
|
|
237
|
+
: "";
|
|
238
|
+
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() +
|
|
239
|
+
'<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(openObligations.slice(0, 3)) + '</section>' +
|
|
240
|
+
'<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>' +
|
|
241
|
+
auditPanel + '</div></div>';
|
|
242
|
+
main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
|
|
261
243
|
}
|
|
262
244
|
|
|
263
245
|
function readinessOverview() {
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
246
|
+
const progress = programPathProgress();
|
|
247
|
+
const nextHref = nextProgramStageHref();
|
|
248
|
+
const programStage = (id, body, href) => {
|
|
249
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
|
|
250
|
+
const current = stageProgress(stage);
|
|
251
|
+
const remaining = current.total - current.complete;
|
|
252
|
+
const status = !remaining ? "Complete" : current.complete ? remaining + " remaining" : current.total + " to confirm";
|
|
253
|
+
return [stage.title, body, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
|
|
254
|
+
};
|
|
272
255
|
const stages = [
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
256
|
+
programStage("scope", "Confirm program ownership, criteria, and commitments, then describe the service, supporting systems, and dependencies.", "#/stage/scope"),
|
|
257
|
+
programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
|
|
258
|
+
programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
|
|
259
|
+
programStage("evidence", "For each control family, finish the source-system instructions and verify a real test export or capture.", "#/stage/evidence"),
|
|
260
|
+
programStage("run", "Begin the candidate period, maintain risk assessments, work the FileGRC queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
|
|
261
|
+
programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
|
|
279
262
|
];
|
|
280
|
-
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>
|
|
263
|
+
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>Program Progress</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program milestones complete") + '</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>';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nextProgramStageHref() {
|
|
267
|
+
const nextStage = READINESS_STAGES.find((stage) => {
|
|
268
|
+
const progress = stageProgress(stage);
|
|
269
|
+
return progress.complete < progress.total;
|
|
270
|
+
}) || READINESS_STAGES.at(-1);
|
|
271
|
+
if (!nextStage) return "#/stage/scope";
|
|
272
|
+
return "#/stage/" + encodeURIComponent(nextStage.id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
276
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === stageId);
|
|
277
|
+
if (!stage) return renderNotFound(main);
|
|
278
|
+
if (stage.id === "run") return renderObligations(main, params);
|
|
279
|
+
const progress = stageProgress(stage);
|
|
280
|
+
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
281
|
+
'<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>' +
|
|
282
|
+
renderStagePageIndex(stage) + '</div>';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderStagePageIndex(stage) {
|
|
286
|
+
const destinations = stagePageDestinations(stage);
|
|
287
|
+
const cards = destinations.map((destination, index) => stagePageCard(stage, destination, index)).join("");
|
|
288
|
+
return '<section class="stage-pages" aria-label="Step ' + esc(stage.number) + ' pages"><div class="stage-page-grid">' + cards + '</div></section>';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stagePageDestinations(stage) {
|
|
292
|
+
return stage.sections.flatMap((section) => sectionDestinations(section)
|
|
293
|
+
.filter((destination) => destination.utility !== "obligation-board")
|
|
294
|
+
.map((destination) => ({ ...destination, section })));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function stagePageId(stage, destination) {
|
|
298
|
+
return stage.id + ":" + (destination.type || "utility:" + destination.utility);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function stagePageComplete(pageId) {
|
|
302
|
+
const completedPageIds = rendererSettingsEntry()?.record.completedStagePageIds || [];
|
|
303
|
+
return [pageId, ...(STAGE_PAGE_ID_ALIASES[pageId] || [])].some((id) => completedPageIds.includes(id));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stagePageSummary(destination) {
|
|
307
|
+
const summaryKey = destination.type || "utility:" + destination.utility;
|
|
308
|
+
const section = destination.section || (destination.type
|
|
309
|
+
? readinessStageForType(destination.type)?.sections.find((candidate) => candidate.types.includes(destination.type))
|
|
310
|
+
: null);
|
|
311
|
+
return STAGE_PAGE_SUMMARIES[summaryKey] || section?.description || "";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function stagePageCard(stage, destination, index) {
|
|
315
|
+
const details = destination.type ? resourceRollup(destination.type) : utilityRollup(destination.utility);
|
|
316
|
+
const summary = stagePageSummary(destination);
|
|
317
|
+
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
318
|
+
const pageId = stagePageId(stage, destination);
|
|
319
|
+
const complete = stagePageComplete(pageId);
|
|
320
|
+
const completionControl = state.readOnly
|
|
321
|
+
? '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + (complete ? "Complete" : "Not marked complete") + '</span>'
|
|
322
|
+
: '<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>';
|
|
323
|
+
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>';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function stageProgress(stage) {
|
|
327
|
+
if (stage.id === "run") return operationProgress();
|
|
328
|
+
const pages = stagePageDestinations(stage);
|
|
329
|
+
const complete = pages.filter((destination) => stagePageComplete(stagePageId(stage, destination))).length;
|
|
330
|
+
return progressFromCounts(complete, pages.length, "page");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function operationProgress() {
|
|
334
|
+
const program = state.programReadiness;
|
|
335
|
+
const goal = program?.target?.goal || state.workspace.assuranceGoal || "none";
|
|
336
|
+
const asOf = program?.asOf || currentDate();
|
|
337
|
+
const candidateStarted = goal === "soc-2-type-2"
|
|
338
|
+
? Boolean(program?.target?.candidatePeriodStart && program.target.candidatePeriodStart <= asOf)
|
|
339
|
+
: goal === "soc-2-type-1"
|
|
340
|
+
? Boolean(program?.target?.candidateTypeOneAsOf)
|
|
341
|
+
: Boolean(program?.evidenceReady);
|
|
342
|
+
const overdue = state.obligations.counts.overdue || 0;
|
|
343
|
+
const complete = Boolean(program?.evidenceReady && candidateStarted && overdue === 0);
|
|
344
|
+
if (complete) {
|
|
345
|
+
return {
|
|
346
|
+
percent: 100,
|
|
347
|
+
complete: 1,
|
|
348
|
+
total: 1,
|
|
349
|
+
status: "Operating",
|
|
350
|
+
tone: "good",
|
|
351
|
+
detail: "Evidence collection is running and the Work Queue has no overdue work."
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (overdue) {
|
|
355
|
+
return {
|
|
356
|
+
percent: 0,
|
|
357
|
+
complete: 0,
|
|
358
|
+
total: 1,
|
|
359
|
+
status: "Needs attention",
|
|
360
|
+
tone: "bad",
|
|
361
|
+
detail: overdue + " overdue Work Queue " + pluralize("item", overdue) + " must be resolved."
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (program?.evidenceReady && goal === "soc-2-type-2" && !candidateStarted) {
|
|
365
|
+
return {
|
|
366
|
+
percent: 0,
|
|
367
|
+
complete: 0,
|
|
368
|
+
total: 1,
|
|
369
|
+
status: "Ready to start",
|
|
370
|
+
tone: "warn",
|
|
371
|
+
detail: "Record the management candidate period start when evidence collection begins."
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
percent: 0,
|
|
376
|
+
complete: 0,
|
|
377
|
+
total: 1,
|
|
378
|
+
status: "Not started",
|
|
379
|
+
tone: "warn",
|
|
380
|
+
detail: "Finish the Evidence Ready work before starting program operation."
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function programPathProgress() {
|
|
385
|
+
const progress = READINESS_STAGES.map((stage) => stageProgress(stage));
|
|
386
|
+
return progressFromCounts(
|
|
387
|
+
progress.reduce((sum, current) => sum + current.complete, 0),
|
|
388
|
+
progress.reduce((sum, current) => sum + current.total, 0),
|
|
389
|
+
"program milestone"
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function progressFromCounts(complete, total, noun) {
|
|
394
|
+
if (!total) return { percent: 0, complete: 0, total: 0, status: "Not started", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
|
|
395
|
+
const percent = Math.round((complete / total) * 100);
|
|
396
|
+
const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked complete.";
|
|
397
|
+
if (complete === total) return { percent: 100, complete, total, status: "Complete", tone: "good", detail };
|
|
398
|
+
if (!complete) return { percent: 0, complete, total, status: "Not started", tone: "warn", detail };
|
|
399
|
+
return { percent, complete, total, status: "In progress", tone: "warn", detail };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function stageProgressCard(progress) {
|
|
403
|
+
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>';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function sectionDestinations(section) {
|
|
407
|
+
const destinations = [];
|
|
408
|
+
for (const type of section.types) {
|
|
409
|
+
const definition = state.model.resources[type];
|
|
410
|
+
if (!definition) continue;
|
|
411
|
+
destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
|
|
412
|
+
}
|
|
413
|
+
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." });
|
|
414
|
+
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." });
|
|
415
|
+
return destinations;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function resourceRollup(type) {
|
|
419
|
+
const records = resourcesOfType(type).map(({ record }) => record);
|
|
420
|
+
if (!records.length) return { value: "0", label: "No records yet" };
|
|
421
|
+
const statuses = new Map();
|
|
422
|
+
records.forEach((record) => {
|
|
423
|
+
if (record.status) statuses.set(record.status, (statuses.get(record.status) || 0) + 1);
|
|
424
|
+
});
|
|
425
|
+
const statusText = [...statuses.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2).map(([status, count]) => count + " " + humanize(status).toLowerCase()).join(" · ");
|
|
426
|
+
return { value: String(records.length), label: statusText || pluralize("record", records.length) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function utilityRollup(utility) {
|
|
430
|
+
if (utility === "obligation-board") {
|
|
431
|
+
const open = state.obligations.items.filter((item) => item.status !== "complete");
|
|
432
|
+
return { value: String(open.length), label: open.length ? "Open work items" : "No work due" };
|
|
433
|
+
}
|
|
434
|
+
if (utility === "audit-packet") {
|
|
435
|
+
const audits = resourcesOfType("audit");
|
|
436
|
+
return { value: String(audits.length), label: audits.length ? pluralize("engagement", audits.length) : "No engagement yet" };
|
|
437
|
+
}
|
|
438
|
+
return { value: "0", label: "Not started" };
|
|
281
439
|
}
|
|
282
440
|
|
|
283
441
|
function auditEngagementPrompt(audit = null) {
|
|
284
442
|
const hasAuditor = audit?.auditorVendorId || (audit?.auditor && Object.keys(audit.auditor).length);
|
|
285
443
|
if (hasAuditor) return "";
|
|
286
|
-
const heading = audit ? "
|
|
287
|
-
return '<div class="audit-engagement"><div><strong>' + heading + '</strong><p>
|
|
444
|
+
const heading = audit ? "CPA Firm Not Recorded" : "Optional: Engage a CPA Firm Early";
|
|
445
|
+
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
446
|
}
|
|
289
447
|
|
|
290
448
|
function renderObligations(main, params = new URLSearchParams()) {
|
|
449
|
+
const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
|
|
291
450
|
const plan = state.obligations;
|
|
451
|
+
const controls = resourcesOfType("control");
|
|
452
|
+
const linkedControlIds = new Set(resourcesOfType("obligation").flatMap(({ record }) => record.controlIds || []));
|
|
453
|
+
const scheduledControls = controls.filter(({ record }) => linkedControlIds.has(record.id)).length;
|
|
454
|
+
const assignedFollowUp = plan.standaloneItems.length;
|
|
292
455
|
const visibleCardLimit = 6;
|
|
293
|
-
const sections = ["upcoming", "due", "overdue"].map((status) => {
|
|
456
|
+
const sections = ["proposed", "upcoming", "due", "overdue"].map((status) => {
|
|
294
457
|
const items = plan.items.filter((item) => item.status === status);
|
|
295
458
|
const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
296
459
|
const more = items.length > visibleCardLimit
|
|
297
460
|
? '<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
461
|
: "";
|
|
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>';
|
|
462
|
+
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
463
|
}).join("");
|
|
301
|
-
const triggers = plan.triggers.map((trigger
|
|
302
|
-
const
|
|
303
|
-
.
|
|
304
|
-
|
|
305
|
-
main.innerHTML = '<div class="page obligation-board-page"><
|
|
464
|
+
const triggers = plan.triggers.map((trigger, index) => policyEventTrigger(trigger, index)).join("");
|
|
465
|
+
const feedback = policyEventFeedback
|
|
466
|
+
? '<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>'
|
|
467
|
+
: "";
|
|
468
|
+
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>' +
|
|
469
|
+
'<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>' +
|
|
470
|
+
feedback +
|
|
471
|
+
'<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>' +
|
|
472
|
+
'<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
473
|
'<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>';
|
|
474
|
+
'</section></div>';
|
|
309
475
|
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
310
476
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
311
477
|
if (trigger) openObligationEventDialog(trigger);
|
|
312
478
|
}));
|
|
313
|
-
main.querySelector("[data-
|
|
479
|
+
main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
480
|
+
main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
|
|
481
|
+
policyEventFeedback = null;
|
|
482
|
+
event.currentTarget.closest(".policy-event-feedback")?.remove();
|
|
483
|
+
});
|
|
314
484
|
main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
|
|
315
485
|
const column = button.closest("[data-obligation-column]");
|
|
316
486
|
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
@@ -323,6 +493,9 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
323
493
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
324
494
|
if (item) openObligationCompletion(item);
|
|
325
495
|
}));
|
|
496
|
+
main.querySelector("[data-new-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
497
|
+
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."
|
|
498
|
+
}));
|
|
326
499
|
const requestedEvent = params.get("event");
|
|
327
500
|
if (requestedEvent || params.get("section") === "events") {
|
|
328
501
|
queueMicrotask(() => {
|
|
@@ -332,16 +505,33 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
332
505
|
}
|
|
333
506
|
}
|
|
334
507
|
|
|
508
|
+
function policyEventTrigger(trigger, index) {
|
|
509
|
+
const tooltipId = "policy-event-tooltip-" + index;
|
|
510
|
+
const proposed = trigger.programStatus === "proposed";
|
|
511
|
+
const unavailable = state.readOnly || proposed;
|
|
512
|
+
const availability = proposed
|
|
513
|
+
? "Available after its governing policies are effective and at least one linked control is implemented."
|
|
514
|
+
: state.readOnly
|
|
515
|
+
? "Open this workspace in writable mode to trigger the workflow."
|
|
516
|
+
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
517
|
+
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>';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function policyEventName(eventType) {
|
|
521
|
+
return POLICY_EVENT_NAMES[eventType] || titleCase(humanize(eventType));
|
|
522
|
+
}
|
|
523
|
+
|
|
335
524
|
function obligationCard(item, collapsed = false) {
|
|
336
525
|
const type = item.actionItemId ? "action-item" : "obligation";
|
|
337
526
|
const id = item.actionItemId || item.obligationId;
|
|
338
527
|
const completion = !item.actionItemId ? obligationCompletionPlan(item) : null;
|
|
339
|
-
const action = !state.readOnly && item.status
|
|
528
|
+
const action = !state.readOnly && !["upcoming", "proposed"].includes(item.status) && completion
|
|
340
529
|
? completion.blocked
|
|
341
530
|
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
342
531
|
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
343
532
|
: "";
|
|
344
|
-
|
|
533
|
+
const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring");
|
|
534
|
+
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
535
|
}
|
|
346
536
|
|
|
347
537
|
function obligationCompletionPlan(item) {
|
|
@@ -428,12 +618,6 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
428
618
|
};
|
|
429
619
|
}
|
|
430
620
|
|
|
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
621
|
function openObligationEventDialog(trigger) {
|
|
438
622
|
const subjectType = trigger.eventType.startsWith("person") || trigger.eventType.includes("person-") ? "person"
|
|
439
623
|
: trigger.eventType.startsWith("vendor") ? "vendor"
|
|
@@ -448,9 +632,9 @@ function openObligationEventDialog(trigger) {
|
|
|
448
632
|
const dialog = document.createElement("dialog");
|
|
449
633
|
dialog.className = "commit-dialog event-dialog";
|
|
450
634
|
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(
|
|
635
|
+
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
636
|
(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.
|
|
637
|
+
'<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
638
|
document.body.append(dialog);
|
|
455
639
|
dialog.showModal();
|
|
456
640
|
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
@@ -474,8 +658,14 @@ function openObligationEventDialog(trigger) {
|
|
|
474
658
|
})
|
|
475
659
|
});
|
|
476
660
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
661
|
+
const created = await response.json();
|
|
662
|
+
policyEventFeedback = {
|
|
663
|
+
name: policyEventName(trigger.eventType),
|
|
664
|
+
taskCount: created.actions?.length || trigger.steps.length
|
|
665
|
+
};
|
|
477
666
|
state = await fetchJson("/api/state");
|
|
478
667
|
dialog.close();
|
|
668
|
+
history.replaceState(null, "", "#/stage/run");
|
|
479
669
|
render();
|
|
480
670
|
} catch (error) {
|
|
481
671
|
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
|
|
@@ -488,6 +678,10 @@ function openObligationEventDialog(trigger) {
|
|
|
488
678
|
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
489
679
|
const audits = resourcesOfType("audit");
|
|
490
680
|
const evidence = resourcesOfType("evidence");
|
|
681
|
+
const fileGRCRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
|
|
682
|
+
.filter((family) => family.collectionTestRequired === false)
|
|
683
|
+
.flatMap((family) => family.operationRecordTypes || []));
|
|
684
|
+
const fileGRCRecords = state.resources.filter(({ record }) => fileGRCRecordTypes.has(record.type));
|
|
491
685
|
const requestedAudit = params.get("auditId");
|
|
492
686
|
const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
|
|
493
687
|
const today = currentDate();
|
|
@@ -499,13 +693,15 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
|
499
693
|
const preflight = [
|
|
500
694
|
["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
|
|
501
695
|
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
502
|
-
["Evidence",
|
|
503
|
-
["
|
|
696
|
+
["FileGRC Evidence", fileGRCRecords.length + " operating " + pluralize("record", fileGRCRecords.length), "#/stage/run", "neutral"],
|
|
697
|
+
["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
|
|
698
|
+
["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
699
|
];
|
|
700
|
+
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
701
|
const dateFields = typeOne
|
|
506
702
|
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
507
703
|
: '<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
|
|
704
|
+
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
705
|
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
510
706
|
const next = event.currentTarget.value;
|
|
511
707
|
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
@@ -600,20 +796,24 @@ function renderPacketResults(container, result) {
|
|
|
600
796
|
const packet = result.packet;
|
|
601
797
|
const ready = packet.readiness.status === "delivery-ready";
|
|
602
798
|
container.innerHTML = '<section class="metrics packet-metrics">' +
|
|
603
|
-
metric("
|
|
799
|
+
metric("FileGRC Evidence", packet.summary.fileGRCRecords, packet.summary.records + " total packet records", "neutral") +
|
|
604
800
|
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") +
|
|
801
|
+
metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
|
|
606
802
|
metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
|
|
607
803
|
'</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>' +
|
|
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
|
|
804
|
+
'<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
805
|
}
|
|
610
806
|
|
|
611
807
|
function obligationPreview(items) {
|
|
612
|
-
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/
|
|
808
|
+
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.");
|
|
613
809
|
}
|
|
614
810
|
|
|
615
811
|
function eventReminderPreview(triggers) {
|
|
616
|
-
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) =>
|
|
812
|
+
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) => {
|
|
813
|
+
const proposed = trigger.programStatus === "proposed";
|
|
814
|
+
const href = proposed ? "#/stage/run?section=events" : "#/stage/run?event=" + encodeURIComponent(trigger.eventType);
|
|
815
|
+
return '<a href="' + href + '"><strong>' + esc(policyEventName(trigger.eventType)) + '</strong><small>' + trigger.steps.length + (proposed ? ' proposed actions' : ' required actions') + '</small></a>';
|
|
816
|
+
}).join("") + '</div>' : empty("No event reminders configured.");
|
|
617
817
|
}
|
|
618
818
|
|
|
619
819
|
function windowText(item) {
|
|
@@ -628,6 +828,7 @@ function windowText(item) {
|
|
|
628
828
|
function timingText(item) {
|
|
629
829
|
if (item.canceledAction) return "Action canceled; resolve or cancel the event";
|
|
630
830
|
if (item.missingCompletion) return "Link required completion proof";
|
|
831
|
+
if (item.status === "proposed") return "Starter proposal";
|
|
631
832
|
if (item.status === "overdue" && Number.isInteger(item.hoursOverdue)) {
|
|
632
833
|
return item.hoursOverdue === 0 ? "Overdue less than 1 hour" : item.hoursOverdue + " hour" + (item.hoursOverdue === 1 ? "" : "s") + " overdue";
|
|
633
834
|
}
|
|
@@ -669,7 +870,12 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
669
870
|
const entries = resourcesOfType(type);
|
|
670
871
|
const requestedPage = Number(params.get("page"));
|
|
671
872
|
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
672
|
-
const fields = [...new Set([
|
|
873
|
+
const fields = [...new Set([
|
|
874
|
+
"title",
|
|
875
|
+
...(definition.listFields || []),
|
|
876
|
+
...(type === "control" ? ["$operationTracking"] : []),
|
|
877
|
+
...(type === "obligation" ? ["$workQueueStatus"] : [])
|
|
878
|
+
])].filter((name) => name !== "title");
|
|
673
879
|
const modelFields = { ...state.model.commonFields, ...definition.fields };
|
|
674
880
|
const filters = Object.entries(modelFields).filter(([, field]) => field.filter).map(([name, field]) => {
|
|
675
881
|
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 +886,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
680
886
|
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
887
|
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
888
|
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 ||
|
|
889
|
+
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
890
|
'<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
891
|
'<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
892
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
@@ -697,7 +903,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
697
903
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
698
904
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
699
905
|
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>';
|
|
906
|
+
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
907
|
pagination.hidden = totalPages === 1;
|
|
702
908
|
previous.disabled = pageNumber === 1;
|
|
703
909
|
next.disabled = pageNumber === totalPages;
|
|
@@ -762,10 +968,28 @@ function renderDetail(main, type, id) {
|
|
|
762
968
|
const addRecordContent = recordContent && !entry.content[recordContent.slot] && !state.readOnly
|
|
763
969
|
? '<div class="record-content-action"><button class="button" type="button" id="add-record-content">Add Record Markdown</button></div>'
|
|
764
970
|
: "";
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
'
|
|
971
|
+
const hasRecordBody = Boolean(narrativeContent || markdownContent);
|
|
972
|
+
const addRecordContentAction = !hasRecordBody && addRecordContent
|
|
973
|
+
? '<button class="button" type="button" id="add-record-content">Add Record Markdown</button>'
|
|
974
|
+
: "";
|
|
975
|
+
const issueActions = state.readOnly
|
|
976
|
+
? ""
|
|
977
|
+
: (FINDING_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-record-finding>Record finding</button>' : "")
|
|
978
|
+
+ (ACTION_ITEM_SOURCE_TYPES.has(type) ? '<button class="button" type="button" data-add-action-item>Add task</button>' : "");
|
|
979
|
+
const detailMain = hasRecordBody
|
|
980
|
+
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
981
|
+
: "";
|
|
982
|
+
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 +
|
|
983
|
+
'<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
984
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
985
|
+
main.querySelector("[data-record-finding]")?.addEventListener("click", () => openEditor("finding", null, {
|
|
986
|
+
seed: issueSeed("finding", entry.record),
|
|
987
|
+
description: "Record only a confirmed gap that needs separate remediation tracking. Keep the report details in this source record’s Markdown."
|
|
988
|
+
}));
|
|
989
|
+
main.querySelector("[data-add-action-item]")?.addEventListener("click", () => openEditor("action-item", null, {
|
|
990
|
+
seed: issueSeed("action-item", entry.record),
|
|
991
|
+
description: "Create a separate task only when this follow-up needs its own assignee, deadline, and completion proof. It will appear in Work Queue."
|
|
992
|
+
}));
|
|
769
993
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
770
994
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
771
995
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
@@ -781,6 +1005,29 @@ function renderDetail(main, type, id) {
|
|
|
781
1005
|
});
|
|
782
1006
|
}
|
|
783
1007
|
|
|
1008
|
+
function issueSeed(type, source) {
|
|
1009
|
+
const owners = source.ownerIds || source.reviewerIds || source.assessorIds || source.testerIds || [];
|
|
1010
|
+
if (type === "finding") {
|
|
1011
|
+
return {
|
|
1012
|
+
title: "Finding from " + source.title,
|
|
1013
|
+
status: "open",
|
|
1014
|
+
severity: "medium",
|
|
1015
|
+
sourceResourceId: source.id,
|
|
1016
|
+
ownerIds: owners,
|
|
1017
|
+
identifiedOn: currentDate(),
|
|
1018
|
+
controlIds: source.controlIds || (source.controlId ? [source.controlId] : []),
|
|
1019
|
+
riskIds: source.riskIds || [],
|
|
1020
|
+
systemIds: source.systemIds || []
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
return {
|
|
1024
|
+
title: "Follow up: " + source.title,
|
|
1025
|
+
status: "open",
|
|
1026
|
+
sourceResourceId: source.id,
|
|
1027
|
+
assigneeIds: owners
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
784
1031
|
function recordNarrative(record, fields) {
|
|
785
1032
|
return Object.entries(record).filter(([name, value]) => RECORD_TEXT_FIELDS.has(name) && fields[name]?.type === "string" && String(value || "").trim());
|
|
786
1033
|
}
|
|
@@ -836,7 +1083,6 @@ function resourceConnections(entry) {
|
|
|
836
1083
|
function navigationResourceTypes() {
|
|
837
1084
|
return [
|
|
838
1085
|
...READINESS_STAGES.flatMap((stage) => stage.sections.flatMap((section) => section.types)),
|
|
839
|
-
...ORGANIZATION_RESOURCE_TYPES,
|
|
840
1086
|
"workspace",
|
|
841
1087
|
"renderer-settings"
|
|
842
1088
|
];
|
|
@@ -845,10 +1091,8 @@ function navigationResourceTypes() {
|
|
|
845
1091
|
function renderOrganization(main) {
|
|
846
1092
|
const workspace = resourcesOfType("workspace")[0];
|
|
847
1093
|
const renderer = rendererSettingsEntry();
|
|
848
|
-
const people = resourcesOfType("person");
|
|
849
|
-
const teams = resourcesOfType("team");
|
|
850
1094
|
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
|
|
1095
|
+
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
1096
|
}
|
|
853
1097
|
|
|
854
1098
|
function renderRepository(main) {
|
|
@@ -876,18 +1120,22 @@ function renderRepository(main) {
|
|
|
876
1120
|
? '<button class="button" type="button" data-git-action="pull" ' + (pullDisabled ? 'disabled title="' + esc(pullDisabled) + '"' : "") + '>Pull with rebase</button>'
|
|
877
1121
|
: "";
|
|
878
1122
|
const commitButton = !state.readOnly && state.git.available && !state.git.clean
|
|
879
|
-
? '<button class="button primary" type="button" id="commit-workspace" ' + (state.
|
|
1123
|
+
? '<button class="button primary" type="button" id="commit-workspace" ' + (!state.git.branch
|
|
1124
|
+
? 'disabled title="Check out a branch before committing"'
|
|
1125
|
+
: state.validation.ok ? "" : 'disabled title="Fix validation errors before committing"') + '>' + (hasRemote ? "Commit and push" : "Commit locally") + '</button>'
|
|
880
1126
|
: "";
|
|
881
1127
|
const pushButton = !state.readOnly && state.git.available && hasRemote
|
|
882
1128
|
? '<button class="button primary" type="button" data-git-action="push" ' + (pushDisabled ? 'disabled title="' + esc(pushDisabled) + '"' : "") + '>Push</button>'
|
|
883
1129
|
: "";
|
|
884
|
-
const repositoryInstructions =
|
|
1130
|
+
const repositoryInstructions = !state.git.branch
|
|
1131
|
+
? "This workspace is on a detached HEAD. Check out a branch before using browser commit, pull, or push."
|
|
1132
|
+
: hasRemote
|
|
885
1133
|
? "Pull remote changes with rebase, review the workspace diff, then commit and push together."
|
|
886
1134
|
: "Review the workspace diff, then commit it locally. Add a Git remote when you want browser pull and push.";
|
|
887
1135
|
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>'
|
|
1136
|
+
? '<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
1137
|
: 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>';
|
|
1138
|
+
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
1139
|
main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
|
|
892
1140
|
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
893
1141
|
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
@@ -980,23 +1228,14 @@ function resourceGuide(type) {
|
|
|
980
1228
|
const definition = state.model.resources[type];
|
|
981
1229
|
const guidance = definition?.guidance;
|
|
982
1230
|
if (!definition || !guidance) return "";
|
|
1231
|
+
const instructions = stagePageSummary({ type });
|
|
983
1232
|
const sources = (guidance.sourceResourceIds || [])
|
|
984
1233
|
.map((id) => state.resources.find(({ record }) => record.id === id))
|
|
985
1234
|
.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
1235
|
const sourceLinks = sources.length
|
|
991
1236
|
? '<div class="guide-links">' + sources.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '">' + esc(record.title) + '</a>').join("") + '</div>'
|
|
992
1237
|
: "";
|
|
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>';
|
|
1238
|
+
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
1239
|
}
|
|
1001
1240
|
|
|
1002
1241
|
function setupResourceGuide(main) {
|
|
@@ -1116,114 +1355,113 @@ function requestOnboarding() {
|
|
|
1116
1355
|
|
|
1117
1356
|
function initialOnboardingDraft() {
|
|
1118
1357
|
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
1358
|
const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
|
|
1123
|
-
const independentApprover = resourcesOfType("person").find(({ record }) => record.id === "person-independent-approver")?.record;
|
|
1124
1359
|
return {
|
|
1125
1360
|
systemId: systemEntry?.record.id || "",
|
|
1126
|
-
auditId: auditEntry?.record.id || "",
|
|
1127
1361
|
serviceName: systemEntry?.record.title || "",
|
|
1128
1362
|
scope: systemEntry?.record.description || "",
|
|
1129
|
-
ownerId: systemEntry?.record.ownerIds?.[0] ||
|
|
1363
|
+
ownerId: systemEntry?.record.ownerIds?.[0] || owner?.id || "",
|
|
1130
1364
|
criticality: systemEntry?.record.criticality || "high",
|
|
1131
1365
|
dataClassification: systemEntry?.record.dataClassification || "Confidential",
|
|
1132
1366
|
internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
|
|
1133
|
-
|
|
1134
|
-
independentApproverName: independentApprover?.title === "Independent Approver" ? "" : independentApprover?.title || "",
|
|
1135
|
-
independentApproverEmail: independentApprover?.email || ""
|
|
1367
|
+
programGoal: programGoalFromKind(state.workspace.assuranceGoal)
|
|
1136
1368
|
};
|
|
1137
1369
|
}
|
|
1138
1370
|
|
|
1139
1371
|
function onboardingSteps() {
|
|
1372
|
+
const files = {
|
|
1373
|
+
target: ".repo-chip",
|
|
1374
|
+
kicker: "Mental model",
|
|
1375
|
+
title: "Files are the program",
|
|
1376
|
+
body: "You or an agent add JSON records, Markdown, and evidence attachments under data/. This renderer edits those files, and Git records their history.",
|
|
1377
|
+
points: [
|
|
1378
|
+
"Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
|
|
1379
|
+
"JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
|
|
1380
|
+
"Repository pulls with rebase, then commits and pushes reviewed changes together.",
|
|
1381
|
+
"Agents and terminal users run git pull --rebase, git commit, and git push directly.",
|
|
1382
|
+
"The dashboard derives program status from the current repository state."
|
|
1383
|
+
]
|
|
1384
|
+
};
|
|
1385
|
+
const path = {
|
|
1386
|
+
target: ".readiness-map",
|
|
1387
|
+
kicker: "Program model",
|
|
1388
|
+
title: "Follow the audit chain",
|
|
1389
|
+
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.",
|
|
1390
|
+
points: [
|
|
1391
|
+
"Scope starts with the people and oversight team, applicable criteria, commitments, material vendors, and in-scope systems.",
|
|
1392
|
+
"Program operation includes current risk assessments and risks, which may add or change controls as conditions change.",
|
|
1393
|
+
"Evidence preparation means cataloging authoritative systems, documenting extraction, and testing captures before the candidate period.",
|
|
1394
|
+
"The CPA firm, formal report period, fieldwork, and final report are the last stage. Engage earlier only when timing or scope needs outside input."
|
|
1395
|
+
]
|
|
1396
|
+
};
|
|
1397
|
+
const obligations = {
|
|
1398
|
+
target: ".obligation-panel",
|
|
1399
|
+
kicker: "Obligations",
|
|
1400
|
+
title: "Work the policy queue",
|
|
1401
|
+
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.",
|
|
1402
|
+
points: [
|
|
1403
|
+
"Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
|
|
1404
|
+
"Link a dated completion record and its evidence to satisfy one occurrence.",
|
|
1405
|
+
"The UI and FileGRC CLI use the same calculation."
|
|
1406
|
+
]
|
|
1407
|
+
};
|
|
1408
|
+
const events = {
|
|
1409
|
+
target: ".event-reminder-panel",
|
|
1410
|
+
kicker: "Triggered work",
|
|
1411
|
+
title: "Complete a checklist when key events occur",
|
|
1412
|
+
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.",
|
|
1413
|
+
points: [
|
|
1414
|
+
"The checklist stays open until every action is done and has the requested completion record or evidence.",
|
|
1415
|
+
"Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
|
|
1416
|
+
"Every action has a policy-based cutoff or a reasonable default deadline for the event.",
|
|
1417
|
+
"Agents start the identical workflow with the FileGRC CLI."
|
|
1418
|
+
]
|
|
1419
|
+
};
|
|
1420
|
+
const reportTypes = {
|
|
1421
|
+
target: null,
|
|
1422
|
+
kicker: "SOC 2 report types",
|
|
1423
|
+
title: "Choose the report goal",
|
|
1424
|
+
body: [
|
|
1425
|
+
"SOC 2 is an independent CPA report on controls relevant to the selected Trust Services Criteria.",
|
|
1426
|
+
"Most customer requests focus on Security. Add Availability, Processing Integrity, Confidentiality, or Privacy only when the service and customer need call for them."
|
|
1427
|
+
],
|
|
1428
|
+
sections: [
|
|
1429
|
+
{
|
|
1430
|
+
title: "Type 1",
|
|
1431
|
+
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."
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
title: "Type 2",
|
|
1435
|
+
body: "A CPA evaluates whether the controls operated consistently throughout an agreed review period, often six months. Dated evidence must cover that period."
|
|
1436
|
+
}
|
|
1437
|
+
],
|
|
1438
|
+
afterSections: "Most evidence comes from production, identity, monitoring, and business systems. FileGRC records where it comes from and how it was collected."
|
|
1439
|
+
};
|
|
1440
|
+
const audit = {
|
|
1441
|
+
target: ".audit-panel",
|
|
1442
|
+
kicker: "Final stage",
|
|
1443
|
+
title: "Engage the firm and prepare fieldwork",
|
|
1444
|
+
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.",
|
|
1445
|
+
points: [
|
|
1446
|
+
"The audit record holds the firm-agreed date or period; the workspace keeps management's earlier candidate dates.",
|
|
1447
|
+
"Audit Readiness identifies missing management documents, populations, exact-period evidence, and request work.",
|
|
1448
|
+
"The CPA firm selects samples, tests controls, evaluates exceptions, and issues the report."
|
|
1449
|
+
]
|
|
1450
|
+
};
|
|
1451
|
+
const setup = {
|
|
1452
|
+
target: null,
|
|
1453
|
+
kicker: "Initial scope",
|
|
1454
|
+
title: "Describe the service you plan to audit",
|
|
1455
|
+
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 confirming the starter people and oversight team, criteria, commitments, vendors, and systems."
|
|
1456
|
+
};
|
|
1140
1457
|
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
|
-
}
|
|
1458
|
+
files,
|
|
1459
|
+
path,
|
|
1460
|
+
obligations,
|
|
1461
|
+
events,
|
|
1462
|
+
reportTypes,
|
|
1463
|
+
audit,
|
|
1464
|
+
setup
|
|
1227
1465
|
];
|
|
1228
1466
|
}
|
|
1229
1467
|
|
|
@@ -1242,7 +1480,10 @@ function renderOnboardingStep() {
|
|
|
1242
1480
|
const body = onboardingStep === steps.length - 1
|
|
1243
1481
|
? onboardingSetupForm()
|
|
1244
1482
|
: description + explanation + afterSections;
|
|
1245
|
-
|
|
1483
|
+
const finalActions = onboardingStep === steps.length - 1
|
|
1484
|
+
? '<button class="button" type="button" data-onboarding="draft">Save draft</button><button class="button primary" type="button" data-onboarding="next">Complete setup</button>'
|
|
1485
|
+
: '<button class="button primary" type="button" data-onboarding="next">Next</button>';
|
|
1486
|
+
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
1487
|
onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
|
|
1247
1488
|
onboardingDialog.querySelector('[data-onboarding="back"]')?.addEventListener("click", () => {
|
|
1248
1489
|
captureOnboardingForm();
|
|
@@ -1250,12 +1491,13 @@ function renderOnboardingStep() {
|
|
|
1250
1491
|
renderOnboardingStep();
|
|
1251
1492
|
});
|
|
1252
1493
|
onboardingDialog.querySelector('[data-onboarding="next"]').addEventListener("click", () => {
|
|
1253
|
-
if (onboardingStep === steps.length - 1) saveOnboarding();
|
|
1494
|
+
if (onboardingStep === steps.length - 1) saveOnboarding(false);
|
|
1254
1495
|
else {
|
|
1255
1496
|
onboardingStep += 1;
|
|
1256
1497
|
renderOnboardingStep();
|
|
1257
1498
|
}
|
|
1258
1499
|
});
|
|
1500
|
+
onboardingDialog.querySelector('[data-onboarding="draft"]')?.addEventListener("click", () => saveOnboarding(true));
|
|
1259
1501
|
const target = onboardingTarget(step);
|
|
1260
1502
|
if (target) {
|
|
1261
1503
|
target.classList.add("onboarding-focus");
|
|
@@ -1275,148 +1517,60 @@ function renderOnboardingStep() {
|
|
|
1275
1517
|
|
|
1276
1518
|
function onboardingSetupForm() {
|
|
1277
1519
|
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
1520
|
const classifications = Object.keys(state.workspace.classificationDefinitions || {});
|
|
1283
1521
|
if (onboardingDraft.dataClassification && !classifications.includes(onboardingDraft.dataClassification)) {
|
|
1284
1522
|
classifications.push(onboardingDraft.dataClassification);
|
|
1285
1523
|
}
|
|
1286
1524
|
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
1525
|
const existing = [
|
|
1289
1526
|
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1290
|
-
|
|
1527
|
+
"Records a management program goal without creating an audit engagement."
|
|
1291
1528
|
].filter(Boolean).join(" ");
|
|
1292
|
-
const gitStatus = state.git.available && state.git.
|
|
1529
|
+
const gitStatus = state.git.available && !state.git.branch
|
|
1530
|
+
? '<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>'
|
|
1531
|
+
: state.git.available && state.git.remotes?.length
|
|
1293
1532
|
? '<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
1533
|
: state.git.available
|
|
1295
1534
|
? '<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
1535
|
: '<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
|
|
1536
|
+
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
1537
|
}
|
|
1299
1538
|
|
|
1300
1539
|
function captureOnboardingForm() {
|
|
1301
1540
|
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1302
1541
|
if (!form) return;
|
|
1303
1542
|
const data = new FormData(form);
|
|
1304
|
-
for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "
|
|
1543
|
+
for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "programGoal"]) {
|
|
1305
1544
|
onboardingDraft[name] = String(data.get(name) || "").trim();
|
|
1306
1545
|
}
|
|
1307
1546
|
}
|
|
1308
1547
|
|
|
1309
|
-
async function saveOnboarding() {
|
|
1548
|
+
async function saveOnboarding(draft = false) {
|
|
1310
1549
|
if (onboardingBusy) return;
|
|
1311
1550
|
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1312
1551
|
if (!form?.reportValidity()) return;
|
|
1313
1552
|
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…");
|
|
1553
|
+
setOnboardingBusy(true, draft ? "Saving draft…" : "Completing…");
|
|
1325
1554
|
try {
|
|
1555
|
+
const response = await localFetch("/api/setup", {
|
|
1556
|
+
method: "POST",
|
|
1557
|
+
headers: { "content-type": "application/json" },
|
|
1558
|
+
body: JSON.stringify({
|
|
1559
|
+
serviceName: onboardingDraft.serviceName,
|
|
1560
|
+
boundary: onboardingDraft.scope,
|
|
1561
|
+
ownerId: onboardingDraft.ownerId,
|
|
1562
|
+
criticality: onboardingDraft.criticality,
|
|
1563
|
+
dataClassification: onboardingDraft.dataClassification,
|
|
1564
|
+
internetExposed: onboardingDraft.internetExposed === "true",
|
|
1565
|
+
programGoal: onboardingDraft.programGoal,
|
|
1566
|
+
systemId: onboardingDraft.systemId,
|
|
1567
|
+
draft
|
|
1568
|
+
})
|
|
1569
|
+
});
|
|
1570
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1326
1571
|
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
1572
|
closeOnboarding();
|
|
1419
|
-
history.replaceState(null, "", "#/");
|
|
1573
|
+
history.replaceState(null, "", draft ? "#/" : "#/stage/scope");
|
|
1420
1574
|
render();
|
|
1421
1575
|
} catch (error) {
|
|
1422
1576
|
setOnboardingBusy(false);
|
|
@@ -1442,11 +1596,11 @@ async function cancelOnboarding() {
|
|
|
1442
1596
|
async function persistOnboardingPreference(showOnboarding) {
|
|
1443
1597
|
const entry = rendererSettingsEntry();
|
|
1444
1598
|
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1445
|
-
await
|
|
1599
|
+
await writeRendererSettingsResource({ ...entry.record, showOnboarding }, entry);
|
|
1446
1600
|
state = await fetchJson("/api/state");
|
|
1447
1601
|
}
|
|
1448
1602
|
|
|
1449
|
-
async function
|
|
1603
|
+
async function writeRendererSettingsResource(record, entry) {
|
|
1450
1604
|
const url = entry
|
|
1451
1605
|
? "/api/resource/" + encodeURIComponent(record.type) + "/" + encodeURIComponent(record.id)
|
|
1452
1606
|
: "/api/resources";
|
|
@@ -1458,6 +1612,24 @@ async function writeOnboardingResource(record, entry) {
|
|
|
1458
1612
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1459
1613
|
}
|
|
1460
1614
|
|
|
1615
|
+
async function toggleStagePageCompletion(button) {
|
|
1616
|
+
const entry = rendererSettingsEntry();
|
|
1617
|
+
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1618
|
+
const completed = new Set(entry.record.completedStagePageIds || []);
|
|
1619
|
+
const pageId = button.dataset.stagePageCompletion;
|
|
1620
|
+
if (button.dataset.complete === "true") {
|
|
1621
|
+
completed.delete(pageId);
|
|
1622
|
+
(STAGE_PAGE_ID_ALIASES[pageId] || []).forEach((id) => completed.delete(id));
|
|
1623
|
+
} else {
|
|
1624
|
+
completed.add(pageId);
|
|
1625
|
+
}
|
|
1626
|
+
await writeRendererSettingsResource({
|
|
1627
|
+
...entry.record,
|
|
1628
|
+
completedStagePageIds: [...completed].sort()
|
|
1629
|
+
}, entry);
|
|
1630
|
+
state = await fetchJson("/api/state");
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1461
1633
|
function setOnboardingBusy(busy, label = "") {
|
|
1462
1634
|
if (!onboardingDialog) return;
|
|
1463
1635
|
onboardingBusy = busy;
|
|
@@ -1563,21 +1735,13 @@ function closeOnboarding() {
|
|
|
1563
1735
|
onboardingDialog.close();
|
|
1564
1736
|
}
|
|
1565
1737
|
|
|
1566
|
-
function
|
|
1738
|
+
function programGoalFromKind(kind) {
|
|
1567
1739
|
if (kind === "soc-2-type-1") return "type-1";
|
|
1568
1740
|
if (kind === "soc-2-type-2") return "type-2";
|
|
1569
1741
|
if (kind === "readiness") return "readiness";
|
|
1570
1742
|
return "none";
|
|
1571
1743
|
}
|
|
1572
1744
|
|
|
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
1745
|
function openEditor(type, entry = null, options = {}) {
|
|
1582
1746
|
const definition = state.model.resources[type];
|
|
1583
1747
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
@@ -1591,7 +1755,13 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1591
1755
|
...Object.entries(state.model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
|
|
1592
1756
|
...(definition.required || [])
|
|
1593
1757
|
]);
|
|
1594
|
-
const
|
|
1758
|
+
const oneOfGroups = (definition.oneOf || []).map((group) => (
|
|
1759
|
+
Array.isArray(group) ? { fields: group } : group
|
|
1760
|
+
));
|
|
1761
|
+
const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
|
|
1762
|
+
const activeOneOf = new Set(oneOfGroups
|
|
1763
|
+
.filter((group) => !group.when || conditionMatches(record, group.when))
|
|
1764
|
+
.flatMap((group) => group.fields || []));
|
|
1595
1765
|
const names = [...new Set([
|
|
1596
1766
|
"title",
|
|
1597
1767
|
...required,
|
|
@@ -1603,23 +1773,29 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
1603
1773
|
dialog.className = "editor";
|
|
1604
1774
|
dialog.setAttribute("aria-labelledby", "resource-editor-title");
|
|
1605
1775
|
const activeMarkdown = markdownDefinitions.filter((markdown) => (
|
|
1606
|
-
markdown.required || markdown.oneOf || !entry || entry.content?.[markdown.name]
|
|
1776
|
+
markdown.required || markdown.requiredWhen || markdown.oneOf || !entry || entry.content?.[markdown.name]
|
|
1607
1777
|
));
|
|
1608
1778
|
const recordContent = recordContentDefinition(type);
|
|
1609
1779
|
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>' +
|
|
1780
|
+
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
1781
|
activeMarkdown.map((markdown) => {
|
|
1612
1782
|
const generated = !entry?.content?.[markdown.name];
|
|
1613
|
-
const source = entry?.content?.[markdown.name]?.source
|
|
1614
|
-
|
|
1615
|
-
|
|
1783
|
+
const source = entry?.content?.[markdown.name]?.source
|
|
1784
|
+
?? (markdown.oneOf ? "" : "# " + (record.title || "New " + definition.title) + "\n\nDescribe this " + definition.title.toLowerCase() + " here.\n");
|
|
1785
|
+
const requiredNow = markdown.required || conditionMatches(record, markdown.requiredWhen);
|
|
1786
|
+
const requiredMark = markdown.required || markdown.requiredWhen
|
|
1787
|
+
? '<span class="required-mark" ' + (requiredNow ? "" : "hidden") + '>Required</span>'
|
|
1788
|
+
: markdown.oneOf
|
|
1789
|
+
? '<span class="required-mark" ' + (activeOneOf.has("$markdown:" + markdown.name) ? "" : "hidden") + '>One Required</span>'
|
|
1790
|
+
: "";
|
|
1791
|
+
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
1792
|
}).join("") + renderRecordContentEditor(type, entry, options) +
|
|
1617
1793
|
'<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
1794
|
document.body.append(dialog);
|
|
1619
1795
|
dialog.showModal();
|
|
1620
1796
|
dialog.addEventListener("close", () => dialog.remove());
|
|
1621
1797
|
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
1622
|
-
wireEditorRequirements(dialog, record, fields,
|
|
1798
|
+
wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
|
|
1623
1799
|
dialog.querySelector(".advanced-editor textarea").addEventListener("input", () => {
|
|
1624
1800
|
dialog.dataset.jsonDirty = "true";
|
|
1625
1801
|
dialog.querySelector("form").noValidate = true;
|
|
@@ -1714,7 +1890,9 @@ function seedRecord(type, definition) {
|
|
|
1714
1890
|
else if (field.values?.length) record[name] = field.values[0];
|
|
1715
1891
|
else record[name] = "";
|
|
1716
1892
|
}
|
|
1717
|
-
for (const
|
|
1893
|
+
for (const group of definition.oneOf || []) {
|
|
1894
|
+
const choices = Array.isArray(group) ? group : group.fields || [];
|
|
1895
|
+
if (!Array.isArray(group) && !conditionMatches(record, group.when)) continue;
|
|
1718
1896
|
if (choices.some((name) => record[name] !== undefined)) continue;
|
|
1719
1897
|
const name = choices.find((candidate) => fields[candidate]);
|
|
1720
1898
|
if (name) record[name] = fields[name].type === "array" ? [] : "";
|
|
@@ -1724,12 +1902,16 @@ function seedRecord(type, definition) {
|
|
|
1724
1902
|
|
|
1725
1903
|
function dedicatedMarkdownDefinitions(type) {
|
|
1726
1904
|
const definition = state.model.resources[type];
|
|
1727
|
-
const choices = new Set((definition.oneOf || [])
|
|
1905
|
+
const choices = new Set((definition.oneOf || [])
|
|
1906
|
+
.flatMap((group) => Array.isArray(group) ? group : group.fields || [])
|
|
1907
|
+
.filter((name) => name.startsWith("$markdown:"))
|
|
1908
|
+
.map((name) => name.slice("$markdown:".length)));
|
|
1728
1909
|
return Object.entries(definition.markdown || {}).map(([name, markdown]) => ({
|
|
1729
1910
|
name,
|
|
1730
1911
|
label: markdown.label || humanize(name),
|
|
1731
1912
|
primary: Boolean(markdown.primary),
|
|
1732
1913
|
required: Boolean(markdown.required),
|
|
1914
|
+
requiredWhen: markdown.requiredWhen || null,
|
|
1733
1915
|
oneOf: choices.has(name)
|
|
1734
1916
|
}));
|
|
1735
1917
|
}
|
|
@@ -1757,10 +1939,10 @@ function renderRecordContentEditor(type, entry, options) {
|
|
|
1757
1939
|
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
1940
|
}
|
|
1759
1941
|
|
|
1760
|
-
function editorField(type, name, field, value, required, editing, oneOfRequired = false) {
|
|
1942
|
+
function editorField(type, name, field, value, required, editing, oneOfRequired = false, oneOfActive = oneOfRequired) {
|
|
1761
1943
|
const label = fieldLabel(type, name);
|
|
1762
1944
|
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
1763
|
-
? '<span class="required-mark" ' + (required ||
|
|
1945
|
+
? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
1764
1946
|
: "";
|
|
1765
1947
|
const help = name === "title"
|
|
1766
1948
|
? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
|
|
@@ -1819,7 +2001,7 @@ function fieldWrap(name, kind, label, requiredMark, control, help, required) {
|
|
|
1819
2001
|
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
2002
|
}
|
|
1821
2003
|
|
|
1822
|
-
function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
2004
|
+
function wireEditorRequirements(dialog, base, fields, oneOfGroups, markdownDefinitions = []) {
|
|
1823
2005
|
const refreshGroup = (group, required) => {
|
|
1824
2006
|
group.dataset.required = required ? "true" : "false";
|
|
1825
2007
|
const mark = group.querySelector(".required-mark");
|
|
@@ -1853,13 +2035,29 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
|
1853
2035
|
if (!field.requiredWhen) continue;
|
|
1854
2036
|
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
1855
2037
|
if (!group) continue;
|
|
1856
|
-
const
|
|
2038
|
+
const visible = !field.visibleWhen || conditionMatchesValues(field.visibleWhen, currentValue);
|
|
2039
|
+
const applicable = Object.entries(field.requiredWhen)
|
|
2040
|
+
.filter(([conditionName]) => conditionName !== "status")
|
|
2041
|
+
.every(([conditionName, expected]) => conditionValueMatches(currentValue(conditionName), expected));
|
|
2042
|
+
group.hidden = !visible || !applicable;
|
|
2043
|
+
const required = visible && applicable && conditionMatchesValues(field.requiredWhen, currentValue);
|
|
1857
2044
|
refreshGroup(group, required);
|
|
1858
2045
|
}
|
|
1859
2046
|
for (const group of dialog.querySelectorAll('[data-kind="relation-array"][data-required="true"]')) {
|
|
1860
2047
|
refreshGroup(group, true);
|
|
1861
2048
|
}
|
|
1862
|
-
for (const
|
|
2049
|
+
for (const markdown of markdownDefinitions) {
|
|
2050
|
+
if (!markdown.requiredWhen) continue;
|
|
2051
|
+
const required = conditionMatchesValues(markdown.requiredWhen, currentValue);
|
|
2052
|
+
const wrapper = dialog.querySelector('[data-content-editor="' + CSS.escape(markdown.name) + '"]');
|
|
2053
|
+
const control = wrapper?.querySelector("textarea");
|
|
2054
|
+
const mark = wrapper?.querySelector(".required-mark");
|
|
2055
|
+
if (control) control.required = required;
|
|
2056
|
+
if (mark) mark.hidden = !required;
|
|
2057
|
+
}
|
|
2058
|
+
for (const definition of oneOfGroups) {
|
|
2059
|
+
const names = definition.fields || [];
|
|
2060
|
+
const active = !definition.when || conditionMatchesValues(definition.when, currentValue);
|
|
1863
2061
|
const choices = names.map((name) => {
|
|
1864
2062
|
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
1865
2063
|
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
@@ -1871,6 +2069,15 @@ function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
|
1871
2069
|
control.required = false;
|
|
1872
2070
|
control.setCustomValidity("");
|
|
1873
2071
|
});
|
|
2072
|
+
for (const name of names) {
|
|
2073
|
+
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
2074
|
+
const wrapper = markdownName
|
|
2075
|
+
? dialog.querySelector('[data-content-editor="' + CSS.escape(markdownName) + '"]')
|
|
2076
|
+
: dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
2077
|
+
const mark = wrapper?.querySelector(".required-mark");
|
|
2078
|
+
if (mark) mark.hidden = !active;
|
|
2079
|
+
}
|
|
2080
|
+
if (!active) continue;
|
|
1874
2081
|
const choice = choices.find(({ present }) => present) || choices[0];
|
|
1875
2082
|
if (choice) {
|
|
1876
2083
|
choice.control.required = true;
|
|
@@ -1941,11 +2148,28 @@ function openContentEditor(entry, name) {
|
|
|
1941
2148
|
}
|
|
1942
2149
|
|
|
1943
2150
|
function bindCommon() {
|
|
1944
|
-
root.querySelectorAll("
|
|
2151
|
+
root.querySelectorAll("[data-stage-page-completion]").forEach((button) => button.addEventListener("click", async (event) => {
|
|
2152
|
+
event.preventDefault();
|
|
2153
|
+
event.stopPropagation();
|
|
2154
|
+
const label = button.textContent;
|
|
2155
|
+
button.disabled = true;
|
|
2156
|
+
button.textContent = "Saving…";
|
|
2157
|
+
try {
|
|
2158
|
+
await toggleStagePageCompletion(button);
|
|
2159
|
+
render();
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
button.disabled = false;
|
|
2162
|
+
button.textContent = label;
|
|
2163
|
+
button.title = error.message;
|
|
2164
|
+
}
|
|
2165
|
+
}));
|
|
2166
|
+
root.querySelectorAll(".nav-toggle, .nav-subgroup-toggle").forEach((button) => button.addEventListener("click", () => {
|
|
1945
2167
|
const group = button.closest(".nav-group");
|
|
1946
2168
|
const open = group.classList.toggle("open");
|
|
1947
2169
|
setNavigationGroupOpen(group.dataset.group, open);
|
|
1948
2170
|
button.setAttribute("aria-expanded", String(open));
|
|
2171
|
+
const label = button.closest(".nav-heading-row, .nav-subheading-row")?.querySelector(".nav-heading, .nav-subheading")?.textContent.trim() || "group";
|
|
2172
|
+
button.setAttribute("aria-label", (open ? "Collapse " : "Expand ") + label);
|
|
1949
2173
|
}));
|
|
1950
2174
|
const navButton = root.querySelector(".mobile-nav");
|
|
1951
2175
|
const sidebar = root.querySelector(".sidebar");
|
|
@@ -1967,6 +2191,15 @@ function bindCommon() {
|
|
|
1967
2191
|
navButton?.focus();
|
|
1968
2192
|
});
|
|
1969
2193
|
const search = root.querySelector("#global-search");
|
|
2194
|
+
const sidebarNavigation = root.querySelector(".sidebar-nav");
|
|
2195
|
+
sidebarNavigation?.addEventListener("scroll", () => {
|
|
2196
|
+
navigationScrollTop = sidebarNavigation.scrollTop;
|
|
2197
|
+
try {
|
|
2198
|
+
window.localStorage.setItem(NAV_SCROLL_STORAGE_KEY, String(navigationScrollTop));
|
|
2199
|
+
} catch {
|
|
2200
|
+
// Browser storage may be unavailable; navigation still preserves the position in memory.
|
|
2201
|
+
}
|
|
2202
|
+
}, { passive: true });
|
|
1970
2203
|
search?.addEventListener("keydown", (event) => {
|
|
1971
2204
|
if (event.key === "Enter" && search.value.trim()) {
|
|
1972
2205
|
event.preventDefault();
|
|
@@ -2097,6 +2330,14 @@ function readNavigationGroupState() {
|
|
|
2097
2330
|
return {};
|
|
2098
2331
|
}
|
|
2099
2332
|
}
|
|
2333
|
+
function readNavigationScrollTop() {
|
|
2334
|
+
try {
|
|
2335
|
+
const value = Number(window.localStorage.getItem(NAV_SCROLL_STORAGE_KEY));
|
|
2336
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
2337
|
+
} catch {
|
|
2338
|
+
return 0;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2100
2341
|
function setNavigationGroupOpen(group, open) {
|
|
2101
2342
|
navigationGroupState[group] = open;
|
|
2102
2343
|
try {
|
|
@@ -2108,6 +2349,9 @@ function setNavigationGroupOpen(group, open) {
|
|
|
2108
2349
|
function groupTitle(id) { return state.model.groups.find((group) => group.id === id)?.title || "Program"; }
|
|
2109
2350
|
function fieldDefinition(type, name) { return state.model.resources[type]?.fields?.[name] || state.model.commonFields[name]; }
|
|
2110
2351
|
function fieldLabel(type, name) {
|
|
2352
|
+
if (name === "$operationTracking") return "Operation tracking";
|
|
2353
|
+
if (name === "$workQueueStatus") return "Work Queue";
|
|
2354
|
+
if (type === "obligation" && name === "status") return "Configuration";
|
|
2111
2355
|
if (name === "title") return state.model.resources[type]?.titleLabel || state.model.commonFields.title.label;
|
|
2112
2356
|
const recordContent = recordContentDefinition(type);
|
|
2113
2357
|
if (recordContent && name === recordContent.slot) return recordContent.label;
|
|
@@ -2127,13 +2371,32 @@ function titleCase(value) {
|
|
|
2127
2371
|
return word.toLowerCase().replace(/(^|[-/])([a-z])/g, (_, boundary, letter) => boundary + letter.toUpperCase());
|
|
2128
2372
|
}).join(" ");
|
|
2129
2373
|
}
|
|
2130
|
-
function conditionMatches(record, condition) {
|
|
2374
|
+
function conditionMatches(record, condition) {
|
|
2375
|
+
return Boolean(condition) && Object.entries(condition).every(([name, expected]) => (
|
|
2376
|
+
Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
|
|
2377
|
+
));
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function conditionMatchesValues(condition, valueFor) {
|
|
2381
|
+
return Boolean(condition) && Object.entries(condition).every(([name, expected]) => {
|
|
2382
|
+
const actual = valueFor(name);
|
|
2383
|
+
return conditionValueMatches(actual, expected);
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function conditionValueMatches(actual, expected) {
|
|
2388
|
+
return Array.isArray(expected) ? expected.includes(actual) : actual === expected;
|
|
2389
|
+
}
|
|
2131
2390
|
function formatValue(value, field, type) {
|
|
2132
2391
|
if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
|
|
2133
2392
|
const definition = fieldDefinition(type, field);
|
|
2134
2393
|
if (definition?.type === "date") return esc(formatCalendarDate(value));
|
|
2135
2394
|
if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
|
|
2136
|
-
if (
|
|
2395
|
+
if (type === "obligation" && field === "status") {
|
|
2396
|
+
const label = value === "active" ? "Enabled" : properCase(value);
|
|
2397
|
+
return '<span class="badge ' + (value === "active" ? "neutral" : "status-" + esc(String(value))) + '">' + esc(label) + '</span>';
|
|
2398
|
+
}
|
|
2399
|
+
if (field === "status" || field.endsWith("Rating") || field === "severity" || field === "outcome") return '<span class="badge status-' + esc(String(value)) + '">' + esc(properCase(value)) + '</span>';
|
|
2137
2400
|
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
2401
|
if (field === "sourceReference" && typeof value === "object") {
|
|
2139
2402
|
const href = safeExternalUrl(value.url);
|
|
@@ -2143,8 +2406,64 @@ function formatValue(value, field, type) {
|
|
|
2143
2406
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
2144
2407
|
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2145
2408
|
if (reference) return '<a class="tag relation" href="#/resource/' + encodeURIComponent(reference.record.type) + '/' + encodeURIComponent(reference.record.id) + '">' + esc(reference.record.title) + '</a>';
|
|
2409
|
+
if (definition?.type === "enum") return esc(properCase(value));
|
|
2146
2410
|
return esc(String(value));
|
|
2147
2411
|
}
|
|
2412
|
+
function controlOperationTracking(control) {
|
|
2413
|
+
const obligations = resourcesOfType("obligation")
|
|
2414
|
+
.map(({ record }) => record)
|
|
2415
|
+
.filter((record) => record.status !== "retired" && (record.controlIds || []).includes(control.id));
|
|
2416
|
+
if (!obligations.length) {
|
|
2417
|
+
return '<span class="operation-tracking evidence"><strong>Not scheduled in Work Queue</strong><small>Show operation with evidence records</small></span>';
|
|
2418
|
+
}
|
|
2419
|
+
const counts = obligations.reduce((result, obligation) => {
|
|
2420
|
+
const status = workQueueScheduleStatus(obligation, control);
|
|
2421
|
+
result[status] = (result[status] || 0) + 1;
|
|
2422
|
+
return result;
|
|
2423
|
+
}, {});
|
|
2424
|
+
const scheduleCount = obligations.length + " " + pluralize("schedule", obligations.length);
|
|
2425
|
+
if (counts.running === obligations.length) {
|
|
2426
|
+
return '<a class="operation-tracking running" href="#/stage/run"><strong>Running in Work Queue</strong><small>' + esc(scheduleCount) + '</small></a>';
|
|
2427
|
+
}
|
|
2428
|
+
if (counts["waiting-policy"] === obligations.length) {
|
|
2429
|
+
return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Waiting for policy approval</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
|
|
2430
|
+
}
|
|
2431
|
+
if (counts["waiting-control"] === obligations.length) {
|
|
2432
|
+
return '<a class="operation-tracking waiting" href="#/stage/run"><strong>Ready when implemented</strong><small>' + esc(scheduleCount) + ' enabled</small></a>';
|
|
2433
|
+
}
|
|
2434
|
+
if (counts.paused === obligations.length) {
|
|
2435
|
+
return '<a class="operation-tracking paused" href="#/stage/run"><strong>Work Queue paused</strong><small>' + esc(scheduleCount) + '</small></a>';
|
|
2436
|
+
}
|
|
2437
|
+
const summary = [
|
|
2438
|
+
counts.running ? counts.running + " running" : "",
|
|
2439
|
+
counts["waiting-policy"] ? counts["waiting-policy"] + " waiting for policy" : "",
|
|
2440
|
+
counts["waiting-control"] ? counts["waiting-control"] + " waiting for implementation" : "",
|
|
2441
|
+
counts.paused ? counts.paused + " paused" : ""
|
|
2442
|
+
].filter(Boolean).join(" · ");
|
|
2443
|
+
return '<a class="operation-tracking mixed" href="#/stage/run"><strong>Work Queue needs attention</strong><small>' + esc(summary) + '</small></a>';
|
|
2444
|
+
}
|
|
2445
|
+
function workQueueScheduleStatus(obligation, control = null) {
|
|
2446
|
+
if (obligation.status === "paused") return "paused";
|
|
2447
|
+
if (obligation.status !== "active") return obligation.status;
|
|
2448
|
+
const asOf = state.obligations?.asOf || new Date().toISOString().slice(0, 10);
|
|
2449
|
+
const policies = (obligation.policyIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
|
|
2450
|
+
if (!policies.every((policy) => policy?.type === "policy" && policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf)) {
|
|
2451
|
+
return "waiting-policy";
|
|
2452
|
+
}
|
|
2453
|
+
const linkedControls = (obligation.controlIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record);
|
|
2454
|
+
const implemented = control
|
|
2455
|
+
? control.status === "implemented"
|
|
2456
|
+
: !linkedControls.length || linkedControls.some((record) => record?.type === "control" && record.status === "implemented");
|
|
2457
|
+
return implemented ? "running" : "waiting-control";
|
|
2458
|
+
}
|
|
2459
|
+
function obligationWorkQueueStatus(obligation) {
|
|
2460
|
+
const status = workQueueScheduleStatus(obligation);
|
|
2461
|
+
if (status === "running") return '<span class="operation-tracking running"><strong>Running</strong><small>Enabled and policy effective</small></span>';
|
|
2462
|
+
if (status === "waiting-policy") return '<span class="operation-tracking waiting"><strong>Waiting for policy</strong><small>Schedule enabled</small></span>';
|
|
2463
|
+
if (status === "waiting-control") return '<span class="operation-tracking waiting"><strong>Waiting for control</strong><small>Implement a linked control</small></span>';
|
|
2464
|
+
if (status === "paused") return '<span class="operation-tracking paused"><strong>Paused</strong><small>Schedule disabled</small></span>';
|
|
2465
|
+
return '<span class="operation-tracking"><strong>' + esc(properCase(status)) + '</strong></span>';
|
|
2466
|
+
}
|
|
2148
2467
|
function formatReference(value) {
|
|
2149
2468
|
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2150
2469
|
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>';
|
|
@@ -2197,34 +2516,40 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
|
|
|
2197
2516
|
|
|
2198
2517
|
export const APP_STYLES = String.raw`
|
|
2199
2518
|
: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:
|
|
2519
|
+
*{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}
|
|
2520
|
+
.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)}
|
|
2521
|
+
.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
2522
|
.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
2523
|
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
2524
|
.topbar{height:63px}
|
|
2206
|
-
.
|
|
2207
|
-
.nav-stage>.nav-items
|
|
2525
|
+
.sidebar-nav{scrollbar-width:none;-ms-overflow-style:none}.sidebar-nav::-webkit-scrollbar{display:none}
|
|
2526
|
+
.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)}
|
|
2527
|
+
.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}
|
|
2528
|
+
.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)}
|
|
2529
|
+
.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}
|
|
2530
|
+
.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
2531
|
.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:
|
|
2532
|
+
.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
2533
|
.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:
|
|
2534
|
+
.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}
|
|
2535
|
+
.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
2536
|
.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-
|
|
2537
|
+
.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}
|
|
2538
|
+
.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}
|
|
2539
|
+
.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}
|
|
2540
|
+
.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}
|
|
2541
|
+
.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}
|
|
2542
|
+
.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}
|
|
2543
|
+
.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}
|
|
2544
|
+
.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
2545
|
@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
2546
|
@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:
|
|
2547
|
+
@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}}
|
|
2548
|
+
@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
2549
|
@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
2550
|
@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(
|
|
2551
|
+
@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}}
|
|
2552
|
+
@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
2553
|
@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
2554
|
|
|
2230
2555
|
body,button,input,select,textarea,dialog{color:var(--ink)}
|
|
@@ -2286,25 +2611,27 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2286
2611
|
.record-table td[data-label]::before{color:var(--muted)}
|
|
2287
2612
|
.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
2613
|
.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:
|
|
2614
|
+
.commit-dialog form>p{color:var(--muted);font-size:13.2px;line-height:1.55}
|
|
2615
|
+
.commit-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}
|
|
2616
|
+
.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
2617
|
.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:
|
|
2618
|
+
.commit-files code{font-size:10.8px;overflow-wrap:anywhere}
|
|
2294
2619
|
.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
|
|
2620
|
+
.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}
|
|
2621
|
+
.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}
|
|
2622
|
+
.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}
|
|
2623
|
+
.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}
|
|
2624
|
+
.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}
|
|
2625
|
+
.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}
|
|
2626
|
+
.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}
|
|
2627
|
+
.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}
|
|
2628
|
+
.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}
|
|
2629
|
+
@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}}
|
|
2630
|
+
@media(max-width:1000px){.stage-overview-layout{grid-template-columns:1fr}.stage-page-grid,.group-destination-grid{grid-template-columns:1fr}}
|
|
2305
2631
|
@media(max-width:760px){.preparation-stages{grid-template-columns:1fr}}
|
|
2306
2632
|
@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}}
|
|
2633
|
+
@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}}
|
|
2634
|
+
@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
2635
|
|
|
2309
2636
|
@media(prefers-color-scheme:dark){
|
|
2310
2637
|
: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 +2639,14 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
2312
2639
|
.badge.good{background:#173b2b;color:#a8edc4}
|
|
2313
2640
|
.badge.warn{background:#483714;color:#ffd991}
|
|
2314
2641
|
.badge.bad{background:#4a252a;color:#ffb5ad}
|
|
2642
|
+
.badge.neutral{background:#252d48;color:#c8cff0}
|
|
2315
2643
|
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#173b2b;color:#a8edc4}
|
|
2316
2644
|
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#4a252a;color:#ffb5ad}
|
|
2317
2645
|
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991}
|
|
2318
2646
|
.badge.status-overdue{background:#4a252a;color:#ffb5ad}
|
|
2319
2647
|
.badge.status-due{background:#483714;color:#ffd991}
|
|
2320
2648
|
.badge.status-complete{background:#173b2b;color:#a8edc4}
|
|
2649
|
+
.policy-event-feedback{border-color:#315f48;background:#183426}.policy-event-feedback p{color:#b7d8c3}
|
|
2321
2650
|
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
2322
2651
|
.status-dot.neutral{background:#9aabff}
|
|
2323
2652
|
}
|