filegrc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/filegrc.js +8 -0
- package/model/index.js +13 -0
- package/model/v1.json +1378 -0
- package/package.json +29 -0
- package/src/agent.js +247 -0
- package/src/audit-preparation.js +906 -0
- package/src/build.js +27 -0
- package/src/cli.js +623 -0
- package/src/evidence-packet.js +1642 -0
- package/src/favicon.js +109 -0
- package/src/files.js +533 -0
- package/src/git.js +289 -0
- package/src/id.js +19 -0
- package/src/index.js +47 -0
- package/src/markdown.js +123 -0
- package/src/model-docs.js +119 -0
- package/src/mutation.js +15 -0
- package/src/obligations.js +595 -0
- package/src/paths.js +137 -0
- package/src/recurrence.js +89 -0
- package/src/resource-markdown.js +63 -0
- package/src/search.js +27 -0
- package/src/server.js +300 -0
- package/src/state.js +84 -0
- package/src/time.js +62 -0
- package/src/validate.js +496 -0
- package/src/web.js +2328 -0
- package/src/workspace.js +90 -0
package/src/web.js
ADDED
|
@@ -0,0 +1,2328 @@
|
|
|
1
|
+
import { createResourceId } from "./id.js";
|
|
2
|
+
import {
|
|
3
|
+
calendarOccurrence,
|
|
4
|
+
calendarOccurrenceIndex,
|
|
5
|
+
formatCalendarDateUtc,
|
|
6
|
+
nextCalendarOccurrence,
|
|
7
|
+
parseCalendarDate,
|
|
8
|
+
utcCalendarDate,
|
|
9
|
+
validCalendarRecurrence
|
|
10
|
+
} from "./recurrence.js";
|
|
11
|
+
import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
12
|
+
|
|
13
|
+
export function renderIndex(state = null) {
|
|
14
|
+
const snapshot = state
|
|
15
|
+
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
16
|
+
: "";
|
|
17
|
+
return `<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8">
|
|
21
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
22
|
+
<meta name="color-scheme" content="light dark">
|
|
23
|
+
<title>FileGRC</title>
|
|
24
|
+
<link rel="icon" type="image/png" href="./favicon.png">
|
|
25
|
+
<link rel="stylesheet" href="./filegrc.css">
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
29
|
+
<div id="app"><div class="loading">Loading workspace…</div></div>
|
|
30
|
+
${snapshot}
|
|
31
|
+
<script src="./filegrc-app.js" defer></script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const APP_SCRIPT = String.raw`
|
|
37
|
+
const root = document.querySelector("#app");
|
|
38
|
+
let state;
|
|
39
|
+
const LIST_PAGE_SIZE = 25;
|
|
40
|
+
const SEARCH_PAGE_SIZE = 25;
|
|
41
|
+
const NAV_GROUP_STORAGE_KEY = "filegrc.sidebar.groups.v3";
|
|
42
|
+
let latestPacketResult = null;
|
|
43
|
+
let latestPacketState = null;
|
|
44
|
+
const READINESS_STAGES = [
|
|
45
|
+
{
|
|
46
|
+
id: "scope",
|
|
47
|
+
number: "1",
|
|
48
|
+
title: "Scope",
|
|
49
|
+
description: "Systems and boundary",
|
|
50
|
+
sections: [
|
|
51
|
+
{ id: "boundary", title: "Boundary", types: ["system", "asset"], nested: true, defaultOpen: true },
|
|
52
|
+
{ id: "dependencies", title: "Dependencies", types: ["vendor", "vendor-review"], nested: true, defaultOpen: false }
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "criteria",
|
|
57
|
+
number: "2",
|
|
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"];
|
|
110
|
+
const OBLIGATION_COMPLETION_TYPES = {
|
|
111
|
+
"access-review": "access-review",
|
|
112
|
+
"backup-test": "backup-test",
|
|
113
|
+
"continuity-review": "evidence",
|
|
114
|
+
exercise: "exercise",
|
|
115
|
+
"inventory-review": "evidence",
|
|
116
|
+
"log-review": "evidence",
|
|
117
|
+
meeting: "meeting",
|
|
118
|
+
"network-review": "evidence",
|
|
119
|
+
"penetration-test": "penetration-test",
|
|
120
|
+
"performance-review": "evidence",
|
|
121
|
+
"policy-review": "policy-review",
|
|
122
|
+
"risk-assessment": "risk-assessment",
|
|
123
|
+
"security-scan": "evidence",
|
|
124
|
+
training: "attestation",
|
|
125
|
+
"vendor-review": "vendor-review",
|
|
126
|
+
"vulnerability-scan": "vulnerability-scan"
|
|
127
|
+
};
|
|
128
|
+
const RECORD_TEXT_FIELDS = new Set(["description", "statement", "activity", "purpose", "scope", "objective", "applicabilityRationale", "summary", "rationale", "acceptanceRationale", "businessPurpose", "changeSummary", "decisionSummary", "decisionRationale", "recommendation", "remediationPlan", "auditorNotes", "notPerformedReason"]);
|
|
129
|
+
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
|
+
const navigationGroupState = readNavigationGroupState();
|
|
131
|
+
let onboardingDialog = null;
|
|
132
|
+
let onboardingShade = null;
|
|
133
|
+
let onboardingStep = 0;
|
|
134
|
+
let onboardingDraft = null;
|
|
135
|
+
let onboardingBusy = false;
|
|
136
|
+
let resourceGuideCleanup = null;
|
|
137
|
+
|
|
138
|
+
start().catch((error) => {
|
|
139
|
+
root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
|
|
140
|
+
root.querySelector("pre").textContent = error.stack || error.message;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function start() {
|
|
144
|
+
const embedded = document.querySelector("#filegrc-data");
|
|
145
|
+
state = embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state");
|
|
146
|
+
window.addEventListener("hashchange", render);
|
|
147
|
+
window.addEventListener("resize", positionCurrentOnboarding);
|
|
148
|
+
window.addEventListener("scroll", positionCurrentOnboarding, true);
|
|
149
|
+
render();
|
|
150
|
+
if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true) {
|
|
151
|
+
queueMicrotask(requestOnboarding);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function render() {
|
|
156
|
+
resourceGuideCleanup?.();
|
|
157
|
+
resourceGuideCleanup = null;
|
|
158
|
+
const route = parseRoute();
|
|
159
|
+
const nav = buildNavigation(route);
|
|
160
|
+
root.innerHTML = '<div class="shell">' + nav + '<div class="workspace"><header class="topbar">' + topbar(route) + '</header><main id="main"></main></div></div>';
|
|
161
|
+
const main = root.querySelector("main");
|
|
162
|
+
if (route.name === "home") renderHome(main);
|
|
163
|
+
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
164
|
+
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
165
|
+
else if (route.name === "list") renderList(main, route.type, route.params);
|
|
166
|
+
else if (route.name === "detail") renderDetail(main, route.type, route.id);
|
|
167
|
+
else if (route.name === "organization") renderOrganization(main);
|
|
168
|
+
else if (route.name === "repository") renderRepository(main);
|
|
169
|
+
else renderNotFound(main);
|
|
170
|
+
bindCommon();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseRoute() {
|
|
174
|
+
const [path, query = ""] = location.hash.replace(/^#\/?/, "").split("?", 2);
|
|
175
|
+
let parts;
|
|
176
|
+
try {
|
|
177
|
+
parts = path.split("/").filter(Boolean).map(decodeURIComponent);
|
|
178
|
+
} catch {
|
|
179
|
+
return { name: "missing" };
|
|
180
|
+
}
|
|
181
|
+
if (!parts.length) return { name: "home" };
|
|
182
|
+
if (parts.length === 1 && parts[0] === "obligations") return { name: "obligations", params: new URLSearchParams(query) };
|
|
183
|
+
if (parts.length === 1 && parts[0] === "audit-packet") return { name: "audit-packet", params: new URLSearchParams(query) };
|
|
184
|
+
if (parts.length === 2 && parts[0] === "resources" && parts[1]) return { name: "list", type: parts[1], params: new URLSearchParams(query) };
|
|
185
|
+
if (parts.length === 3 && parts[0] === "resource" && parts[1] && parts[2]) return { name: "detail", type: parts[1], id: parts[2] };
|
|
186
|
+
if (parts.length === 1 && parts[0] === "organization") return { name: "organization" };
|
|
187
|
+
if (parts.length === 1 && parts[0] === "repository") return { name: "repository" };
|
|
188
|
+
return { name: "missing" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildNavigation(route) {
|
|
192
|
+
const currentStage = readinessStageForRoute(route);
|
|
193
|
+
const stages = READINESS_STAGES.map((stage) => {
|
|
194
|
+
const stageOpen = navigationGroupState[stage.id] ?? currentStage?.id === stage.id;
|
|
195
|
+
const sections = stage.sections.map((section) => {
|
|
196
|
+
const sectionKey = stage.id + ":" + section.id;
|
|
197
|
+
const sectionCurrent = (route.type && section.types.includes(route.type))
|
|
198
|
+
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
199
|
+
|| (section.utility === "audit-packet" && route.name === "audit-packet");
|
|
200
|
+
const sectionOpen = navigationGroupState[sectionKey] ?? (sectionCurrent || section.defaultOpen);
|
|
201
|
+
const resources = section.types
|
|
202
|
+
.map((type) => [type, state.model.resources[type]])
|
|
203
|
+
.filter(([, definition]) => definition);
|
|
204
|
+
const links = renderSidebarUtility(section.utility, route, !section.nested) + resources.map(([type, definition]) => {
|
|
205
|
+
return '<a class="' + (!section.nested ? "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>';
|
|
206
|
+
}).join("");
|
|
207
|
+
if (!section.nested) return links;
|
|
208
|
+
return '<section class="nav-group nav-subgroup ' + (sectionOpen ? "open" : "") + '" data-group="' + esc(sectionKey) + '"><button class="nav-subheading" type="button" aria-expanded="' + sectionOpen + '" aria-controls="nav-group-' + esc(sectionKey) + '"><span>' + esc(section.title) + '</span><span class="chevron nav-control">›</span></button><div class="nav-items" id="nav-group-' + esc(sectionKey) + '">' + links + '</div></section>';
|
|
209
|
+
}).join("");
|
|
210
|
+
return '<section class="nav-group nav-stage ' + (stageOpen ? "open" : "") + '" data-group="' + esc(stage.id) + '"><button class="nav-heading" type="button" aria-expanded="' + stageOpen + '" aria-controls="nav-group-' + esc(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><span class="chevron nav-control">›</span></button><div class="nav-items" id="nav-group-' + esc(stage.id) + '">' + sections + '</div></section>';
|
|
211
|
+
}).join("");
|
|
212
|
+
const organizationCurrent = route.name === "organization" || route.name === "repository" || ["workspace", "renderer-settings", ...ORGANIZATION_RESOURCE_TYPES].includes(route.type);
|
|
213
|
+
const organizationName = state.workspace.organizationName || "Organization";
|
|
214
|
+
const initial = organizationName.trim().charAt(0).toUpperCase() || "O";
|
|
215
|
+
return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>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
|
+
}
|
|
217
|
+
|
|
218
|
+
function readinessStageForRoute(route) {
|
|
219
|
+
return READINESS_STAGES.find((stage) => stage.sections.some((section) => section.types.includes(route.type)
|
|
220
|
+
|| (section.utility === "obligation-board" && route.name === "obligations")
|
|
221
|
+
|| (section.utility === "audit-packet" && route.name === "audit-packet")));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readinessStageForType(type) {
|
|
225
|
+
return READINESS_STAGES.find((stage) => stage.sections.some((section) => section.types.includes(type)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderSidebarUtility(utility, route, direct = false) {
|
|
229
|
+
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
|
+
}
|
|
233
|
+
if (utility === "audit-packet") {
|
|
234
|
+
return '<a class="' + directClass + 'audit-packet-link ' + (route.name === "audit-packet" ? "current" : "") + '" href="#/audit-packet"><span>Audit Readiness</span><span class="nav-control-slot" aria-hidden="true"></span></a>';
|
|
235
|
+
}
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function topbar(route) {
|
|
240
|
+
const title = route.name === "home"
|
|
241
|
+
? "Program Overview"
|
|
242
|
+
: route.name === "organization"
|
|
243
|
+
? "Organization"
|
|
244
|
+
: route.name === "repository"
|
|
245
|
+
? "Repository"
|
|
246
|
+
: route.name === "obligations"
|
|
247
|
+
? "Obligation Board"
|
|
248
|
+
: route.name === "audit-packet"
|
|
249
|
+
? "Audit Readiness"
|
|
250
|
+
: state.model.resources[route.type]?.pluralTitle || "FileGRC";
|
|
251
|
+
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + (state.git.clean ? "good" : "warn") + '"></span>' + esc(state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</a></div>';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderHome(main) {
|
|
255
|
+
const activeAudit = resourcesOfType("audit").find((item) => !["complete", "closed", "cancelled"].includes(item.record.status));
|
|
256
|
+
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>' + readinessOverview() +
|
|
257
|
+
'<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>Due Windows</h3></div><a href="#/obligations">Open board</a></div>' + obligationPreview(state.obligations.items.filter((item) => item.status !== "complete").slice(0, 3)) + '</section>' +
|
|
258
|
+
'<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="#/obligations?section=events">Start workflow</a></div>' + eventReminderPreview(state.obligations.triggers.slice(0, 4)) + '</section>' +
|
|
259
|
+
'<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Audit activity</p><h3>' + esc(titleCase(activeAudit?.record.title || "Plan the Engagement")) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Plan audit</a>') + '</div>' +
|
|
260
|
+
(activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section></div></div>';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readinessOverview() {
|
|
264
|
+
const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired");
|
|
265
|
+
const requirements = resourcesOfType("requirement").filter(({ record }) => record.applicability === "applicable");
|
|
266
|
+
const policies = resourcesOfType("policy").filter(({ record }) => !["superseded", "retired"].includes(record.status));
|
|
267
|
+
const approvedPolicies = policies.filter(({ record }) => ["approved", "active"].includes(record.status));
|
|
268
|
+
const controls = resourcesOfType("control");
|
|
269
|
+
const confirmedControls = controls.filter(({ record }) => record.status !== "planned");
|
|
270
|
+
const evidence = resourcesOfType("evidence");
|
|
271
|
+
const activeAudit = resourcesOfType("audit").find(({ record }) => !["complete", "closed", "cancelled"].includes(record.status));
|
|
272
|
+
const stages = [
|
|
273
|
+
["Scope", "Define the service, system boundary, people, data, and vendors.", inScopeSystems.length ? "#/resources/system" : "#/resources/system?new=1", inScopeSystems.length + " in-scope " + pluralize("system", inScopeSystems.length), inScopeSystems.length ? "good" : "warn"],
|
|
274
|
+
["Criteria", "Record what the auditor will evaluate and whether it applies.", "#/resources/requirement", requirements.length + " applicable", requirements.length ? "neutral" : "warn"],
|
|
275
|
+
["Policies", "Set the rules and responsibilities the company adopts.", "#/resources/policy", approvedPolicies.length + " of " + policies.length + " approved", approvedPolicies.length === policies.length && policies.length ? "good" : "warn"],
|
|
276
|
+
["Controls", "Confirm the repeatable work that satisfies those rules and criteria.", "#/resources/control", confirmedControls.length + " of " + controls.length + " reviewed", confirmedControls.length === controls.length && controls.length ? "good" : "warn"],
|
|
277
|
+
["Operate Controls", "Complete recurring and event work, then attach dated evidence.", "#/obligations", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.due + " due · " + evidence.length + " evidence", state.obligations.counts.overdue ? "bad" : state.obligations.counts.due ? "warn" : "good"],
|
|
278
|
+
["Audit", "Track the firm, date or period, requests, evidence packet, findings, and report.", activeAudit ? "#/resources/audit" : "#/resources/audit?new=1", activeAudit ? "Engagement active" : "Not planned", activeAudit ? "good" : "neutral"]
|
|
279
|
+
];
|
|
280
|
+
return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Follow the Audit Chain</h3></div><p>An auditor traces the system in scope to criteria, company rules, operating controls, and proof that the controls were implemented at the Type 1 date or operated during the Type 2 period.</p></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>';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function auditEngagementPrompt(audit = null) {
|
|
284
|
+
const hasAuditor = audit?.auditorVendorId || (audit?.auditor && Object.keys(audit.auditor).length);
|
|
285
|
+
if (hasAuditor) return "";
|
|
286
|
+
const heading = audit ? "Auditor Not Recorded" : "Engage an Auditor Before the Target Date";
|
|
287
|
+
return '<div class="audit-engagement"><div><strong>' + heading + '</strong><p>Shortlist independent CPA firms that perform SOC 2 examinations. Share the system boundary, Security scope, Type 1 or Type 2 goal, and target timing.</p></div><ul><li>Compare relevant service experience, schedule, fee, and evidence-request process.</li><li>Agree on scope and dates before the evidence period or as early as practical.</li><li>Store the selected firm and contact in the audit record.</li></ul>' + (!audit ? '<a class="button primary" href="#/resources/audit?new=1">Create audit record</a>' : "") + '</div>';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function renderObligations(main, params = new URLSearchParams()) {
|
|
291
|
+
const plan = state.obligations;
|
|
292
|
+
const visibleCardLimit = 6;
|
|
293
|
+
const sections = ["upcoming", "due", "overdue"].map((status) => {
|
|
294
|
+
const items = plan.items.filter((item) => item.status === status);
|
|
295
|
+
const cards = items.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
296
|
+
const more = items.length > visibleCardLimit
|
|
297
|
+
? '<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
|
+
: "";
|
|
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>';
|
|
300
|
+
}).join("");
|
|
301
|
+
const triggers = plan.triggers.map((trigger) => '<article class="event-trigger-card"><div><p class="kicker">' + esc(trigger.eventType) + '</p><h3>' + esc(titleCase(trigger.prompt)) + '</h3><p>' + trigger.steps.length + ' policy actions will be created with their own owners and due windows.</p></div><ol>' + trigger.steps.map((step) => '<li><span>' + esc(step.title) + '</span><small>' + esc(eventStepSummary(step)) + '</small></li>').join("") + '</ol>' + (!state.readOnly ? '<button class="button primary" type="button" data-start-event="' + esc(trigger.eventType) + '">Start workflow</button>' : "") + '</article>').join("");
|
|
302
|
+
const runs = plan.eventRuns
|
|
303
|
+
.filter((run) => run.status !== "canceled")
|
|
304
|
+
.sort((a, b) => String(b.occurredAt || b.occurredOn).localeCompare(String(a.occurredAt || a.occurredOn)));
|
|
305
|
+
main.innerHTML = '<div class="page obligation-board-page"><div class="page-intro"><div><p class="kicker">Policy work queue</p><h2>Obligation Board</h2><p>Recurring work shows when a task may be completed and the date it becomes overdue. Policy events create a tracked checklist when something changes.</p></div><div class="page-actions"><button class="button" type="button" data-scroll-events>Start policy event</button><a class="button" href="#/resources/obligation">Edit templates</a></div></div>' +
|
|
306
|
+
'<div class="obligation-board">' + sections + '</div>' +
|
|
307
|
+
'<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Ongoing reminders</p><h2>Start a Policy Event</h2><p>Use these when the underlying event happens. The generated checklist remains a normal set of Git-tracked records.</p></div></div><div class="event-trigger-grid">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
|
|
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>';
|
|
309
|
+
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
310
|
+
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
311
|
+
if (trigger) openObligationEventDialog(trigger);
|
|
312
|
+
}));
|
|
313
|
+
main.querySelector("[data-scroll-events]")?.addEventListener("click", () => main.querySelector(".event-reminders")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
314
|
+
main.querySelectorAll("[data-expand-obligations]").forEach((button) => button.addEventListener("click", () => {
|
|
315
|
+
const column = button.closest("[data-obligation-column]");
|
|
316
|
+
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
317
|
+
column.querySelectorAll(".obligation-card[data-collapsed]").forEach((card) => { card.hidden = expanded; });
|
|
318
|
+
button.setAttribute("aria-expanded", String(!expanded));
|
|
319
|
+
button.textContent = expanded ? "Show " + (Number(button.dataset.total) - visibleCardLimit) + " more" : "Show fewer";
|
|
320
|
+
if (expanded) column.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
321
|
+
}));
|
|
322
|
+
main.querySelectorAll("[data-record-obligation]").forEach((button) => button.addEventListener("click", () => {
|
|
323
|
+
const item = plan.items.find((candidate) => candidate.key === button.dataset.recordObligation);
|
|
324
|
+
if (item) openObligationCompletion(item);
|
|
325
|
+
}));
|
|
326
|
+
const requestedEvent = params.get("event");
|
|
327
|
+
if (requestedEvent || params.get("section") === "events") {
|
|
328
|
+
queueMicrotask(() => {
|
|
329
|
+
main.querySelector(".event-reminders")?.scrollIntoView({ block: "start" });
|
|
330
|
+
if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function obligationCard(item, collapsed = false) {
|
|
336
|
+
const type = item.actionItemId ? "action-item" : "obligation";
|
|
337
|
+
const id = item.actionItemId || item.obligationId;
|
|
338
|
+
const completion = !item.actionItemId ? obligationCompletionPlan(item) : null;
|
|
339
|
+
const action = !state.readOnly && item.status !== "upcoming" && completion
|
|
340
|
+
? completion.blocked
|
|
341
|
+
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
|
|
342
|
+
: '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
|
|
343
|
+
: "";
|
|
344
|
+
return '<article class="obligation-card status-' + esc(item.status) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(item.kind === "event" ? "Event action" : item.activityType || "Recurring") + '</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
|
+
}
|
|
346
|
+
|
|
347
|
+
function obligationCompletionPlan(item) {
|
|
348
|
+
const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
|
|
349
|
+
if (["access-review", "backup-test"].includes(type) && !resourcesOfType("system").some(({ record }) => record.inScope && record.status !== "retired")) {
|
|
350
|
+
return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
|
|
351
|
+
}
|
|
352
|
+
if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
|
|
353
|
+
return { type, blocked: "Add vendor first", href: "#/resources/vendor?new=1" };
|
|
354
|
+
}
|
|
355
|
+
if (type === "meeting" && !resourcesOfType("team").length) {
|
|
356
|
+
return { type, blocked: "Add team first", href: "#/resources/team?new=1" };
|
|
357
|
+
}
|
|
358
|
+
return { type };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function openObligationCompletion(item) {
|
|
362
|
+
const obligation = state.resources.find(({ record }) => record.type === "obligation" && record.id === item.obligationId);
|
|
363
|
+
if (!obligation) return showError("The obligation template could not be found.");
|
|
364
|
+
const completion = obligationCompletionPlan(item);
|
|
365
|
+
if (completion.blocked) return;
|
|
366
|
+
openEditor(completion.type, null, {
|
|
367
|
+
seed: obligationCompletionSeed(completion.type, item, obligation.record),
|
|
368
|
+
obligationCompletion: {
|
|
369
|
+
obligationId: item.obligationId,
|
|
370
|
+
revision: obligation.revision
|
|
371
|
+
},
|
|
372
|
+
description: "Record the work performed during this occurrence. Saving creates the dated record and links it to the obligation. Add supporting evidence on this record or as a linked evidence record." + (item.status === "overdue" ? " The missed occurrence remains overdue when work is performed after its policy cutoff." : ""),
|
|
373
|
+
saveLabel: "Save and link"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function obligationCompletionSeed(type, item, obligation) {
|
|
378
|
+
const date = currentDate();
|
|
379
|
+
const timestamp = new Date().toISOString();
|
|
380
|
+
const people = resourcesOfType("person").filter(({ record }) => record.status === "active").map(({ record }) => record.id);
|
|
381
|
+
const ownerIds = (item.ownerIds || []).filter((id) => people.includes(id));
|
|
382
|
+
const responsiblePeople = ownerIds.length ? ownerIds : people.slice(0, 1);
|
|
383
|
+
const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired").map(({ record }) => record.id);
|
|
384
|
+
const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
|
|
385
|
+
const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
|
|
386
|
+
const common = { title };
|
|
387
|
+
if (type === "meeting") {
|
|
388
|
+
return { ...common, status: "complete", teamId: resourcesOfType("team")[0]?.record.id, chairIds: responsiblePeople, scheduledOn: date };
|
|
389
|
+
}
|
|
390
|
+
if (type === "policy-review") {
|
|
391
|
+
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", changesRequired: false, periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
|
|
392
|
+
}
|
|
393
|
+
if (type === "risk-assessment") {
|
|
394
|
+
return { ...common, status: "complete", assessmentDate: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: responsiblePeople, methodology: state.workspace.riskMethodology?.method || "Documented risk methodology", approvedOn: date };
|
|
395
|
+
}
|
|
396
|
+
if (type === "attestation") {
|
|
397
|
+
return { ...common, status: "completed", subjectResourceIds: [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter(Boolean), personId: responsiblePeople[0], attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
|
|
398
|
+
}
|
|
399
|
+
if (type === "access-review") {
|
|
400
|
+
return { ...common, status: "complete", reviewDate: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
|
|
401
|
+
}
|
|
402
|
+
if (type === "vulnerability-scan") {
|
|
403
|
+
return { ...common, status: "complete", scanKind: "vulnerability", scope: "In-scope systems", operatorIds: responsiblePeople, scheduledOn: date, completedAt: timestamp, systemIds: inScopeSystems, resultSummary: "Document the scan result and link findings or evidence." };
|
|
404
|
+
}
|
|
405
|
+
if (type === "penetration-test") {
|
|
406
|
+
return { ...common, status: "complete", testKind: "independent", scope: "In-scope systems and service boundary", periodStart: date, periodEnd: date, ownerIds: responsiblePeople, outcome: "passed", systemIds: inScopeSystems };
|
|
407
|
+
}
|
|
408
|
+
if (type === "exercise") {
|
|
409
|
+
return { ...common, status: "complete", exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response", scheduledOn: date, facilitatorIds: responsiblePeople, objective: item.title, outcome: "passed", systemIds: inScopeSystems, completedAt: timestamp };
|
|
410
|
+
}
|
|
411
|
+
if (type === "backup-test") {
|
|
412
|
+
return { ...common, status: "passed", systemIds: inScopeSystems, testDate: date, operatorIds: responsiblePeople, outcome: "passed", completedAt: timestamp };
|
|
413
|
+
}
|
|
414
|
+
if (type === "vendor-review") {
|
|
415
|
+
return { ...common, status: "complete", vendorIds: activeVendors, reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
...common,
|
|
419
|
+
status: "collected",
|
|
420
|
+
evidenceKind: item.activityType || "control-operation",
|
|
421
|
+
source: "Internal control operation",
|
|
422
|
+
collectedOn: date,
|
|
423
|
+
classification: "Internal",
|
|
424
|
+
periodStart: item.dueWindowStart,
|
|
425
|
+
periodEnd: item.dueWindowEnd,
|
|
426
|
+
controlIds: item.controlIds || [],
|
|
427
|
+
sourceResourceIds: [item.obligationId]
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
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
|
+
function openObligationEventDialog(trigger) {
|
|
438
|
+
const subjectType = trigger.eventType.startsWith("person") || trigger.eventType.includes("person-") ? "person"
|
|
439
|
+
: trigger.eventType.startsWith("vendor") ? "vendor"
|
|
440
|
+
: trigger.eventType.startsWith("system") ? "system"
|
|
441
|
+
: trigger.eventType.includes("incident") ? "incident"
|
|
442
|
+
: null;
|
|
443
|
+
const subjects = subjectType ? resourcesOfType(subjectType) : [];
|
|
444
|
+
const needsTimestamp = trigger.steps.some((step) => Number.isInteger(step.window?.endOffsetHours));
|
|
445
|
+
const eventField = needsTimestamp
|
|
446
|
+
? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
|
|
447
|
+
: '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
|
|
448
|
+
const dialog = document.createElement("dialog");
|
|
449
|
+
dialog.className = "commit-dialog event-dialog";
|
|
450
|
+
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(titleCase(trigger.prompt)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>This creates one event record and ' + trigger.steps.length + ' linked action items. Review and commit them like any other compliance change.</p>' + eventField +
|
|
452
|
+
(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.prompt.replace(/\?$/, "")) + '"></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">Create checklist</button></div></form>';
|
|
454
|
+
document.body.append(dialog);
|
|
455
|
+
dialog.showModal();
|
|
456
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
457
|
+
dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close());
|
|
458
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
459
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
const form = event.currentTarget;
|
|
462
|
+
if (!form.reportValidity()) return;
|
|
463
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
|
|
464
|
+
try {
|
|
465
|
+
const response = await localFetch("/api/obligation-events", {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { "content-type": "application/json" },
|
|
468
|
+
body: JSON.stringify({
|
|
469
|
+
eventType: trigger.eventType,
|
|
470
|
+
occurredOn: form.elements.occurredOn?.value || undefined,
|
|
471
|
+
occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
|
|
472
|
+
subjectResourceIds: form.elements.subject?.value ? [form.elements.subject.value] : [],
|
|
473
|
+
title: form.elements.title.value
|
|
474
|
+
})
|
|
475
|
+
});
|
|
476
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
477
|
+
state = await fetchJson("/api/state");
|
|
478
|
+
dialog.close();
|
|
479
|
+
render();
|
|
480
|
+
} catch (error) {
|
|
481
|
+
form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
|
|
482
|
+
form.querySelector(".dialog-error").textContent = error.message;
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
dialog.querySelector('input[name="occurredOn"], input[name="occurredAt"]').focus();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderAuditPacket(main, params = new URLSearchParams()) {
|
|
489
|
+
const audits = resourcesOfType("audit");
|
|
490
|
+
const evidence = resourcesOfType("evidence");
|
|
491
|
+
const requestedAudit = params.get("auditId");
|
|
492
|
+
const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
|
|
493
|
+
const today = currentDate();
|
|
494
|
+
const start = selected?.periodStart || selected?.typeOneAsOf || today.slice(0, 4) + "-01-01";
|
|
495
|
+
const end = selected?.periodEnd || selected?.typeOneAsOf || today;
|
|
496
|
+
const typeOne = selected?.auditKind === "soc-2-type-1";
|
|
497
|
+
const draft = !state.git.clean || !selected;
|
|
498
|
+
const preparation = state.auditPreparations?.[selected?.id || "none"] || state.auditPreparations?.none;
|
|
499
|
+
const preflight = [
|
|
500
|
+
["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
|
|
501
|
+
["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
|
|
502
|
+
["Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "warn"],
|
|
503
|
+
["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.due + " due", "#/obligations", state.obligations.counts.overdue ? "bad" : state.obligations.counts.due ? "warn" : "good"]
|
|
504
|
+
];
|
|
505
|
+
const dateFields = typeOne
|
|
506
|
+
? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
|
|
507
|
+
: '<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 readiness and evidence</p><h2>Prepare the Audit</h2><p>Complete management-owned scope, adoption, documents, source-system evidence, and Type 2 population reconciliation here. FileGRC builds a delivery index, control matrix, source-system and external-evidence indexes, source records, attachments, history, and checksums for the engagement date or period.</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>' + 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
|
+
main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
|
|
510
|
+
const next = event.currentTarget.value;
|
|
511
|
+
location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
|
|
512
|
+
});
|
|
513
|
+
main.querySelector("#initialize-audit-work")?.addEventListener("click", async (event) => {
|
|
514
|
+
const button = event.currentTarget;
|
|
515
|
+
const error = main.querySelector(".audit-preparation-error");
|
|
516
|
+
button.disabled = true;
|
|
517
|
+
button.textContent = "Initializing…";
|
|
518
|
+
error.textContent = "";
|
|
519
|
+
try {
|
|
520
|
+
const response = await localFetch("/api/audit-preparation", {
|
|
521
|
+
method: "POST",
|
|
522
|
+
headers: { "content-type": "application/json" },
|
|
523
|
+
body: JSON.stringify({ auditId: selected.id })
|
|
524
|
+
});
|
|
525
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
526
|
+
state = await fetchJson("/api/state");
|
|
527
|
+
render();
|
|
528
|
+
} catch (caught) {
|
|
529
|
+
error.textContent = caught.message;
|
|
530
|
+
button.disabled = false;
|
|
531
|
+
button.textContent = "Initialize audit work";
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
main.querySelector("#packet-form").addEventListener("submit", async (event) => {
|
|
535
|
+
event.preventDefault();
|
|
536
|
+
const form = event.currentTarget;
|
|
537
|
+
if (!form.reportValidity()) return;
|
|
538
|
+
const button = form.querySelector('button[type="submit"]');
|
|
539
|
+
const error = main.querySelector(".dialog-error");
|
|
540
|
+
button.disabled = true;
|
|
541
|
+
button.textContent = "Generating…";
|
|
542
|
+
error.textContent = "";
|
|
543
|
+
try {
|
|
544
|
+
const response = await localFetch("/api/evidence-packet", {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: { "content-type": "application/json" },
|
|
547
|
+
body: JSON.stringify({
|
|
548
|
+
start: form.elements.start.value,
|
|
549
|
+
end: form.elements.end?.value || form.elements.start.value,
|
|
550
|
+
auditId: form.elements.auditId.value || undefined
|
|
551
|
+
})
|
|
552
|
+
});
|
|
553
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
554
|
+
latestPacketResult = await response.json();
|
|
555
|
+
latestPacketState = state;
|
|
556
|
+
const results = root.querySelector("#packet-results");
|
|
557
|
+
if (results) renderPacketResults(results, latestPacketResult);
|
|
558
|
+
} catch (caught) {
|
|
559
|
+
error.textContent = caught.message;
|
|
560
|
+
} finally {
|
|
561
|
+
button.disabled = state.readOnly;
|
|
562
|
+
button.textContent = draft ? "Generate draft" : "Generate packet";
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
if (latestPacketResult && latestPacketState === state && latestPacketResult.packet.audit?.id === selected?.id) {
|
|
566
|
+
renderPacketResults(main.querySelector("#packet-results"), latestPacketResult);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderAuditPreparation(preparation) {
|
|
571
|
+
if (!preparation) return "";
|
|
572
|
+
const setupButton = preparation.canInitialize && !state.readOnly
|
|
573
|
+
? '<button class="button primary" type="button" id="initialize-audit-work">Initialize audit work</button>'
|
|
574
|
+
: "";
|
|
575
|
+
const heading = preparation.audit ? preparation.audit.title : "No Audit Selected";
|
|
576
|
+
const status = preparation.status === "management-ready" ? "Management work complete" : preparation.counts.action + " actions remaining";
|
|
577
|
+
const stages = preparation.stages.map((stage) => {
|
|
578
|
+
const items = stage.items.map((item) => {
|
|
579
|
+
const href = item.resourceId
|
|
580
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
581
|
+
: item.resourceType
|
|
582
|
+
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
583
|
+
: "";
|
|
584
|
+
const content = '<span class="preparation-status ' + esc(item.status) + '" aria-hidden="true">' + preparationStatusMark(item.status) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(item.message) + '</small></span>';
|
|
585
|
+
return href ? '<a href="' + href + '">' + content + '</a>' : '<div>' + content + '</div>';
|
|
586
|
+
}).join("");
|
|
587
|
+
return '<details class="preparation-stage" ' + (stage.status === "action" ? "open" : "") + '><summary><span><strong>' + esc(stage.title) + '</strong><small>' + esc(stage.description) + '</small></span><b>' + (stage.counts.action ? stage.counts.action + " remaining" : stage.counts.later ? "Later in fieldwork" : stage.id === "auditor" ? "Auditor owned" : "Complete") + '</b></summary><div class="preparation-items">' + items + '</div></details>';
|
|
588
|
+
}).join("");
|
|
589
|
+
return '<section class="panel audit-preparation"><div class="panel-head"><div><p class="kicker">Management preparation</p><h3>' + esc(heading) + '</h3><p>' + esc(preparation.progress.complete + " of " + preparation.progress.total + " management items complete · " + status) + '</p></div>' + setupButton + '</div><div class="preparation-progress" aria-label="' + esc(preparation.progress.percent + "% complete") + '"><span style="width:' + preparation.progress.percent + '%"></span></div><p class="audit-preparation-note">Initialization creates engagement-specific management documents from the starter templates and, for Type 2, the standard population plan. It does not approve policies, mark controls implemented, or invent evidence.</p><div class="audit-preparation-error dialog-error" role="alert"></div><div class="preparation-stages">' + stages + '</div></section>';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function preparationStatusMark(status) {
|
|
593
|
+
if (status === "complete") return "✓";
|
|
594
|
+
if (status === "action") return "!";
|
|
595
|
+
if (status === "later") return "◷";
|
|
596
|
+
return "→";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderPacketResults(container, result) {
|
|
600
|
+
const packet = result.packet;
|
|
601
|
+
const ready = packet.readiness.status === "delivery-ready";
|
|
602
|
+
container.innerHTML = '<section class="metrics packet-metrics">' +
|
|
603
|
+
metric("Dated records", packet.summary.datedRecords, packet.summary.records + " total source records", "neutral") +
|
|
604
|
+
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") +
|
|
606
|
+
metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
|
|
607
|
+
'</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "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 evidence records matched.")) + '</section></div>';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function obligationPreview(items) {
|
|
612
|
+
return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/obligations"><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
|
+
}
|
|
614
|
+
|
|
615
|
+
function eventReminderPreview(triggers) {
|
|
616
|
+
return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) => '<a href="#/obligations?event=' + encodeURIComponent(trigger.eventType) + '"><strong>' + esc(trigger.prompt) + '</strong><small>' + trigger.steps.length + ' required actions</small></a>').join("") + '</div>' : empty("No event reminders configured.");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function windowText(item) {
|
|
620
|
+
if (item.dueWindowEndAt) {
|
|
621
|
+
return formatLocalDateTime(item.dueWindowStartAt) + " through " + formatLocalDateTime(item.dueWindowEndAt) + ". Overdue after that cutoff.";
|
|
622
|
+
}
|
|
623
|
+
return item.dueWindowEnd
|
|
624
|
+
? formatCalendarDate(item.dueWindowStart) + " through " + formatCalendarDate(item.dueWindowEnd) + ". Overdue " + formatCalendarDate(item.overdueOn) + "."
|
|
625
|
+
: "Deadline unavailable; review the source obligation.";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function timingText(item) {
|
|
629
|
+
if (item.canceledAction) return "Action canceled; resolve or cancel the event";
|
|
630
|
+
if (item.missingCompletion) return "Link required completion proof";
|
|
631
|
+
if (item.status === "overdue" && Number.isInteger(item.hoursOverdue)) {
|
|
632
|
+
return item.hoursOverdue === 0 ? "Overdue less than 1 hour" : item.hoursOverdue + " hour" + (item.hoursOverdue === 1 ? "" : "s") + " overdue";
|
|
633
|
+
}
|
|
634
|
+
if (item.status === "overdue") return item.daysOverdue === 0 ? "Overdue today" : item.daysOverdue + " day" + (item.daysOverdue === 1 ? "" : "s") + " overdue";
|
|
635
|
+
if (item.status === "due" && Number.isInteger(item.hoursUntilOverdue)) {
|
|
636
|
+
return item.hoursUntilOverdue === 0 ? "Cutoff now" : item.hoursUntilOverdue + " hour" + (item.hoursUntilOverdue === 1 ? "" : "s") + " until overdue";
|
|
637
|
+
}
|
|
638
|
+
if (item.status === "due") return item.overdueOn ? item.daysUntilOverdue + " day" + (item.daysUntilOverdue === 1 ? "" : "s") + " until overdue" : "Due now";
|
|
639
|
+
if (item.status === "upcoming" && Number.isInteger(item.hoursUntilStart)) {
|
|
640
|
+
return "Opens in " + item.hoursUntilStart + " hour" + (item.hoursUntilStart === 1 ? "" : "s");
|
|
641
|
+
}
|
|
642
|
+
if (item.status === "upcoming") return "Opens in " + item.daysUntilStart + " day" + (item.daysUntilStart === 1 ? "" : "s");
|
|
643
|
+
return "Complete";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function relativeEventWindow(window) {
|
|
647
|
+
if (Number.isInteger(window?.endOffsetHours)) {
|
|
648
|
+
return window.endOffsetHours === 0 ? "Due at the event time" : "Due within " + window.endOffsetHours + " hours";
|
|
649
|
+
}
|
|
650
|
+
if (Number.isInteger(window?.endOffsetDays)) {
|
|
651
|
+
return window.endOffsetDays === 0 ? "Due on the event date" : "Due within " + window.endOffsetDays + " days";
|
|
652
|
+
}
|
|
653
|
+
return "Due within 30 days";
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function eventStepSummary(step) {
|
|
657
|
+
const owners = (step.ownerIds || []).map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
658
|
+
const proof = (step.completionResourceTypes || []).map(humanize);
|
|
659
|
+
return [
|
|
660
|
+
relativeEventWindow(step.window),
|
|
661
|
+
owners.length ? "Owner: " + owners.join(", ") : "",
|
|
662
|
+
proof.length ? "Proof: " + proof.join(" or ") : ""
|
|
663
|
+
].filter(Boolean).join(" · ");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderList(main, type, params = new URLSearchParams()) {
|
|
667
|
+
const definition = state.model.resources[type];
|
|
668
|
+
if (!definition) return renderNotFound(main);
|
|
669
|
+
const entries = resourcesOfType(type);
|
|
670
|
+
const requestedPage = Number(params.get("page"));
|
|
671
|
+
let pageNumber = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1;
|
|
672
|
+
const fields = [...new Set(["title", ...(definition.listFields || [])])].filter((name) => name !== "title");
|
|
673
|
+
const modelFields = { ...state.model.commonFields, ...definition.fields };
|
|
674
|
+
const filters = Object.entries(modelFields).filter(([, field]) => field.filter).map(([name, field]) => {
|
|
675
|
+
const observed = entries.flatMap(({ record }) => Array.isArray(record[name]) ? record[name] : [record[name]]).filter((value) => ["string", "number", "boolean"].includes(typeof value)).map(String);
|
|
676
|
+
const values = [...new Set(observed)].sort();
|
|
677
|
+
return { name, label: field.label || humanize(name), values };
|
|
678
|
+
}).filter(({ values }) => values.length > 1);
|
|
679
|
+
const createButton = !state.readOnly && !definition.singleton ? '<button class="button primary" id="new-resource">New ' + esc(definition.title.toLowerCase()) + '</button>' : "";
|
|
680
|
+
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
|
+
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
|
+
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 || (ORGANIZATION_RESOURCE_TYPES.includes(type) ? "Organization" : groupTitle(definition.group))) + '</p><div class="page-title-line"><h2>' + esc(titleCase(definition.pluralTitle)) + '</h2>' + guideTrigger + '</div></div>' + listTools + '</div>' + resourceGuide(type) +
|
|
684
|
+
'<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
|
+
'<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
|
+
resourceGuideCleanup = setupResourceGuide(main);
|
|
687
|
+
const pagination = main.querySelector(".list-pagination");
|
|
688
|
+
const pageStatus = pagination.querySelector(".page-status");
|
|
689
|
+
const previous = pagination.querySelector('[data-page="previous"]');
|
|
690
|
+
const next = pagination.querySelector('[data-page="next"]');
|
|
691
|
+
const renderRows = () => {
|
|
692
|
+
const query = main.querySelector("#list-search").value.toLowerCase();
|
|
693
|
+
const selections = [...main.querySelectorAll(".field-filter")].filter((select) => select.value).map((select) => [select.dataset.field, select.value]);
|
|
694
|
+
const filtered = entries.filter((entry) => (!query || entrySearchText(entry).includes(query)) && selections.every(([field, expected]) => Array.isArray(entry.record[field]) ? entry.record[field].map(String).includes(expected) : String(entry.record[field] ?? "") === expected));
|
|
695
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / LIST_PAGE_SIZE));
|
|
696
|
+
pageNumber = Math.min(pageNumber, totalPages);
|
|
697
|
+
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
698
|
+
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
699
|
+
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>';
|
|
701
|
+
pagination.hidden = totalPages === 1;
|
|
702
|
+
previous.disabled = pageNumber === 1;
|
|
703
|
+
next.disabled = pageNumber === totalPages;
|
|
704
|
+
const firstVisible = filtered.length ? start + 1 : 0;
|
|
705
|
+
const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
|
|
706
|
+
pageStatus.textContent = "Page " + pageNumber + " of " + totalPages + " · " + firstVisible + "–" + lastVisible + " of " + filtered.length;
|
|
707
|
+
};
|
|
708
|
+
main.querySelector("#list-search").value = params.get("q") || "";
|
|
709
|
+
main.querySelectorAll(".field-filter").forEach((select) => { select.value = params.get(select.dataset.field) || ""; });
|
|
710
|
+
const syncRoute = (mode = "replace") => {
|
|
711
|
+
const next = new URLSearchParams();
|
|
712
|
+
const query = main.querySelector("#list-search").value.trim();
|
|
713
|
+
if (query) next.set("q", query);
|
|
714
|
+
main.querySelectorAll(".field-filter").forEach((select) => { if (select.value) next.set(select.dataset.field, select.value); });
|
|
715
|
+
if (pageNumber > 1) next.set("page", String(pageNumber));
|
|
716
|
+
history[mode + "State"](null, "", "#/resources/" + encodeURIComponent(type) + (next.size ? "?" + next : ""));
|
|
717
|
+
};
|
|
718
|
+
const updateFilters = () => {
|
|
719
|
+
pageNumber = 1;
|
|
720
|
+
renderRows();
|
|
721
|
+
syncRoute();
|
|
722
|
+
};
|
|
723
|
+
main.querySelector("#list-search").addEventListener("input", updateFilters);
|
|
724
|
+
main.querySelectorAll(".field-filter").forEach((select) => select.addEventListener("change", updateFilters));
|
|
725
|
+
previous.addEventListener("click", () => {
|
|
726
|
+
pageNumber -= 1;
|
|
727
|
+
renderRows();
|
|
728
|
+
syncRoute("push");
|
|
729
|
+
root.querySelector(".workspace").scrollTo({ top: 0 });
|
|
730
|
+
});
|
|
731
|
+
next.addEventListener("click", () => {
|
|
732
|
+
pageNumber += 1;
|
|
733
|
+
renderRows();
|
|
734
|
+
syncRoute("push");
|
|
735
|
+
root.querySelector(".workspace").scrollTo({ top: 0 });
|
|
736
|
+
});
|
|
737
|
+
renderRows();
|
|
738
|
+
syncRoute();
|
|
739
|
+
main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
|
|
740
|
+
if (params.get("new") === "1" && !state.readOnly && !definition.singleton) queueMicrotask(() => openEditor(type));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function renderDetail(main, type, id) {
|
|
744
|
+
const entry = resourcesOfType(type).find(({ record }) => record.id === id);
|
|
745
|
+
const definition = state.model.resources[type];
|
|
746
|
+
if (!entry || !definition) return renderNotFound(main);
|
|
747
|
+
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
748
|
+
const recordContent = recordContentDefinition(type);
|
|
749
|
+
const narrative = recordNarrative(entry.record, fields);
|
|
750
|
+
const narrativeNames = new Set(narrative.map(([name]) => name));
|
|
751
|
+
const visible = Object.entries(entry.record).filter(([name]) => (
|
|
752
|
+
!["schemaVersion", "id", "type", "title"].includes(name)
|
|
753
|
+
&& !fields[name]?.content
|
|
754
|
+
&& !narrativeNames.has(name)
|
|
755
|
+
));
|
|
756
|
+
const content = Object.entries(entry.content);
|
|
757
|
+
const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
|
|
758
|
+
const narrativeContent = narrative.length
|
|
759
|
+
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
760
|
+
: "";
|
|
761
|
+
const markdownContent = content.map(([name, item]) => '<article class="markdown"><div class="content-label"><span>' + esc(fieldLabel(type, name)) + ' · ' + esc(item.path) + '</span>' + (!state.readOnly ? '<button class="text-button" data-edit-content="' + esc(name) + '">Edit Markdown</button>' : "") + '</div>' + item.html + '</article>').join("");
|
|
762
|
+
const addRecordContent = recordContent && !entry.content[recordContent.slot] && !state.readOnly
|
|
763
|
+
? '<div class="record-content-action"><button class="button" type="button" id="add-record-content">Add Record Markdown</button></div>'
|
|
764
|
+
: "";
|
|
765
|
+
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) + '">Evidence packet</a>' : "") + (!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"><section class="panel detail-main">' +
|
|
766
|
+
(narrativeContent || markdownContent ? narrativeContent + markdownContent + addRecordContent : '<div class="panel-head"><h3>Record</h3></div>' + empty("Add Record Markdown when this record needs context beyond its structured fields.") + addRecordContent) +
|
|
767
|
+
'</section><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
|
+
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
769
|
+
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
770
|
+
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
771
|
+
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
772
|
+
if (!confirm('Delete "' + entry.record.title + '"? Use deletion only for mistakes and uncommitted drafts. Unshared Markdown authored for this record will also be deleted.')) return;
|
|
773
|
+
try {
|
|
774
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision), { method: "DELETE" });
|
|
775
|
+
if (!response.ok) return showError(await responseMessage(response));
|
|
776
|
+
state = await fetchJson("/api/state");
|
|
777
|
+
location.hash = "#/resources/" + encodeURIComponent(type);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
showError(error.message);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function recordNarrative(record, fields) {
|
|
785
|
+
return Object.entries(record).filter(([name, value]) => RECORD_TEXT_FIELDS.has(name) && fields[name]?.type === "string" && String(value || "").trim());
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function recordContentDefinition(type) {
|
|
789
|
+
const config = state.model.recordContent;
|
|
790
|
+
const definition = state.model.resources[type];
|
|
791
|
+
if (!definition || !config?.slot || definition.markdown) return null;
|
|
792
|
+
return {
|
|
793
|
+
slot: config.slot,
|
|
794
|
+
label: config.label,
|
|
795
|
+
mode: config.defaultResourceTypes.includes(type) ? "default" : "optional"
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function resourceConnections(entry) {
|
|
800
|
+
const connections = new Map();
|
|
801
|
+
const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
|
|
802
|
+
const add = (connectedEntry, reason) => {
|
|
803
|
+
if (!connectedEntry || connectedEntry.record.id === entry.record.id) return;
|
|
804
|
+
const existing = connections.get(connectedEntry.record.id) || { entry: connectedEntry, reasons: new Set() };
|
|
805
|
+
existing.reasons.add(reason);
|
|
806
|
+
connections.set(connectedEntry.record.id, existing);
|
|
807
|
+
};
|
|
808
|
+
const currentFields = { ...state.model.commonFields, ...state.model.resources[entry.record.type].fields };
|
|
809
|
+
Object.entries(currentFields).forEach(([name, definition]) => {
|
|
810
|
+
if (!definition.relation) return;
|
|
811
|
+
const values = Array.isArray(entry.record[name]) ? entry.record[name] : [entry.record[name]];
|
|
812
|
+
values.filter((value) => typeof value === "string").forEach((id) => add(entriesById.get(id), "Linked from " + fieldLabel(entry.record.type, name)));
|
|
813
|
+
});
|
|
814
|
+
state.resources.forEach((candidate) => {
|
|
815
|
+
if (candidate.record.id === entry.record.id) return;
|
|
816
|
+
const fields = { ...state.model.commonFields, ...state.model.resources[candidate.record.type].fields };
|
|
817
|
+
Object.entries(fields).forEach(([name, definition]) => {
|
|
818
|
+
if (!definition.relation) return;
|
|
819
|
+
const values = Array.isArray(candidate.record[name]) ? candidate.record[name] : [candidate.record[name]];
|
|
820
|
+
if (values.includes(entry.record.id)) add(candidate, "Linked by " + state.model.resources[candidate.record.type].title + " · " + fieldLabel(candidate.record.type, name));
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
const typeOrder = navigationResourceTypes();
|
|
824
|
+
const sorted = [...connections.values()].sort((first, second) => {
|
|
825
|
+
const firstIndex = typeOrder.indexOf(first.entry.record.type);
|
|
826
|
+
const secondIndex = typeOrder.indexOf(second.entry.record.type);
|
|
827
|
+
const firstType = firstIndex < 0 ? Number.MAX_SAFE_INTEGER : firstIndex;
|
|
828
|
+
const secondType = secondIndex < 0 ? Number.MAX_SAFE_INTEGER : secondIndex;
|
|
829
|
+
return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
|
|
830
|
+
});
|
|
831
|
+
if (!sorted.length) return "";
|
|
832
|
+
const visible = sorted.slice(0, 14);
|
|
833
|
+
return '<section class="panel connections-panel"><div class="panel-head"><h3>Connections</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function navigationResourceTypes() {
|
|
837
|
+
return [
|
|
838
|
+
...READINESS_STAGES.flatMap((stage) => stage.sections.flatMap((section) => section.types)),
|
|
839
|
+
...ORGANIZATION_RESOURCE_TYPES,
|
|
840
|
+
"workspace",
|
|
841
|
+
"renderer-settings"
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function renderOrganization(main) {
|
|
846
|
+
const workspace = resourcesOfType("workspace")[0];
|
|
847
|
+
const renderer = rendererSettingsEntry();
|
|
848
|
+
const people = resourcesOfType("person");
|
|
849
|
+
const teams = resourcesOfType("team");
|
|
850
|
+
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 the organization records that supply ownership, accountability, shared teams, and renderer behavior across the compliance program.</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-directory"><div class="panel-head"><div><p class="kicker">Directory</p><h3>People and Teams</h3></div></div><div class="organization-links"><a href="#/resources/person"><span><strong>People</strong><small>Owners, approvers, trainees, reviewers, and contacts</small></span><b>' + people.length + '</b></a><a href="#/resources/team"><span><strong>Teams</strong><small>Committees, response groups, and shared ownership</small></span><b>' + teams.length + '</b></a></div></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
|
+
}
|
|
853
|
+
|
|
854
|
+
function renderRepository(main) {
|
|
855
|
+
const settings = rendererSettingsEntry();
|
|
856
|
+
const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
|
|
857
|
+
const onboardingButton = settings && !state.readOnly ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>' : "";
|
|
858
|
+
const hasRemote = Boolean(state.git.upstream || state.git.remotes?.length);
|
|
859
|
+
const pullDisabled = !state.git.clean
|
|
860
|
+
? "Commit or discard workspace changes before pulling"
|
|
861
|
+
: !state.git.branch
|
|
862
|
+
? "Check out a branch before pulling"
|
|
863
|
+
: !state.git.upstream
|
|
864
|
+
? "Push this branch first or configure an upstream"
|
|
865
|
+
: "";
|
|
866
|
+
const pushDisabled = !state.git.clean
|
|
867
|
+
? "Commit or discard workspace changes before pushing"
|
|
868
|
+
: !state.validation.ok
|
|
869
|
+
? "Fix validation errors before pushing"
|
|
870
|
+
: !state.git.branch
|
|
871
|
+
? "Check out a branch before pushing"
|
|
872
|
+
: !state.git.upstream && !state.git.remotes?.length
|
|
873
|
+
? "Add a Git remote before pushing"
|
|
874
|
+
: "";
|
|
875
|
+
const pullButton = !state.readOnly && state.git.available && hasRemote
|
|
876
|
+
? '<button class="button" type="button" data-git-action="pull" ' + (pullDisabled ? 'disabled title="' + esc(pullDisabled) + '"' : "") + '>Pull with rebase</button>'
|
|
877
|
+
: "";
|
|
878
|
+
const commitButton = !state.readOnly && state.git.available && !state.git.clean
|
|
879
|
+
? '<button class="button primary" type="button" id="commit-workspace" ' + (state.validation.ok ? "" : 'disabled title="Fix validation errors before committing"') + '>' + (hasRemote ? "Commit and push" : "Commit locally") + '</button>'
|
|
880
|
+
: "";
|
|
881
|
+
const pushButton = !state.readOnly && state.git.available && hasRemote
|
|
882
|
+
? '<button class="button primary" type="button" data-git-action="push" ' + (pushDisabled ? 'disabled title="' + esc(pushDisabled) + '"' : "") + '>Push</button>'
|
|
883
|
+
: "";
|
|
884
|
+
const repositoryInstructions = hasRemote
|
|
885
|
+
? "Pull remote changes with rebase, review the workspace diff, then commit and push together."
|
|
886
|
+
: "Review the workspace diff, then commit it locally. Add a Git remote when you want browser pull and push.";
|
|
887
|
+
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>'
|
|
889
|
+
: 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>';
|
|
891
|
+
main.querySelector("#commit-workspace")?.addEventListener("click", openCommitDialog);
|
|
892
|
+
main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
|
|
893
|
+
main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function runRepositoryGitAction(action) {
|
|
897
|
+
const buttons = [...document.querySelectorAll("[data-git-action]")];
|
|
898
|
+
const disabled = buttons.map((button) => button.disabled);
|
|
899
|
+
const active = buttons.find((button) => button.dataset.gitAction === action);
|
|
900
|
+
const status = document.querySelector(".repository-sync-status");
|
|
901
|
+
const label = action === "pull" ? "Pulling…" : "Pushing…";
|
|
902
|
+
if (status) {
|
|
903
|
+
status.textContent = "";
|
|
904
|
+
status.classList.remove("error");
|
|
905
|
+
}
|
|
906
|
+
buttons.forEach((button) => { button.disabled = true; });
|
|
907
|
+
if (active) active.textContent = label;
|
|
908
|
+
try {
|
|
909
|
+
const response = await localFetch("/api/git/" + action, { method: "POST" });
|
|
910
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
911
|
+
const result = await response.json();
|
|
912
|
+
state = await fetchJson("/api/state");
|
|
913
|
+
render();
|
|
914
|
+
const currentStatus = document.querySelector(".repository-sync-status");
|
|
915
|
+
if (currentStatus) currentStatus.textContent = action === "pull"
|
|
916
|
+
? result.updated
|
|
917
|
+
? "Pulled " + result.upstream + " with rebase at " + result.shortCommit + "."
|
|
918
|
+
: result.branch + " is current with " + result.upstream + "."
|
|
919
|
+
: "Pushed " + result.shortCommit + " to " + result.upstream + ".";
|
|
920
|
+
} catch (cause) {
|
|
921
|
+
buttons.forEach((button, index) => { button.disabled = disabled[index]; });
|
|
922
|
+
if (active) active.textContent = action === "pull" ? "Pull with rebase" : "Push";
|
|
923
|
+
if (status) {
|
|
924
|
+
status.textContent = cause.message;
|
|
925
|
+
status.classList.add("error");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function openCommitDialog() {
|
|
931
|
+
const hasRemote = Boolean(state.git.upstream || state.git.remotes?.length);
|
|
932
|
+
const actionLabel = hasRemote ? "Commit and push" : "Commit locally";
|
|
933
|
+
const busyLabel = hasRemote ? "Committing and pushing…" : "Committing…";
|
|
934
|
+
const dialog = document.createElement("dialog");
|
|
935
|
+
dialog.className = "commit-dialog";
|
|
936
|
+
dialog.setAttribute("aria-labelledby", "commit-dialog-title");
|
|
937
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this FileGRC workspace, then push the commit to its Git remote." : "Commit every change under this FileGRC workspace. Add a Git remote later when you are ready to sync it.") + ' Use a message that explains why the compliance records changed.</p><label><span>Commit message</span><input name="message" required maxlength="200" placeholder="Record quarterly access review"></label><div class="commit-files">' + state.git.changes.map((change) => '<code>' + esc(change) + '</code>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-commit="cancel">Cancel</button><button type="submit" class="button primary">' + actionLabel + '</button></div></form>';
|
|
938
|
+
document.body.append(dialog);
|
|
939
|
+
dialog.showModal();
|
|
940
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
941
|
+
dialog.querySelector('[data-commit="cancel"]').addEventListener("click", () => dialog.close());
|
|
942
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
943
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
944
|
+
event.preventDefault();
|
|
945
|
+
const form = event.currentTarget;
|
|
946
|
+
if (!form.reportValidity()) return;
|
|
947
|
+
const button = form.querySelector('button[type="submit"]');
|
|
948
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
|
|
949
|
+
button.textContent = busyLabel;
|
|
950
|
+
try {
|
|
951
|
+
const response = await localFetch("/api/commit", {
|
|
952
|
+
method: "POST",
|
|
953
|
+
headers: { "content-type": "application/json" },
|
|
954
|
+
body: JSON.stringify({ message: form.elements.message.value })
|
|
955
|
+
});
|
|
956
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
957
|
+
const result = await response.json();
|
|
958
|
+
state = await fetchJson("/api/state");
|
|
959
|
+
dialog.close();
|
|
960
|
+
render();
|
|
961
|
+
const status = document.querySelector(".repository-sync-status");
|
|
962
|
+
if (status) {
|
|
963
|
+
status.textContent = result.pushed
|
|
964
|
+
? "Committed and pushed " + result.shortCommit + " to " + result.upstream + "."
|
|
965
|
+
: result.pushSkipped
|
|
966
|
+
? "Committed " + result.shortCommit + " locally. Add a Git remote when you are ready to sync."
|
|
967
|
+
: "Committed " + result.shortCommit + " locally, but the push failed. " + result.pushError;
|
|
968
|
+
status.classList.toggle("error", !result.pushed && !result.pushSkipped);
|
|
969
|
+
}
|
|
970
|
+
} catch (error) {
|
|
971
|
+
form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
|
|
972
|
+
button.textContent = actionLabel;
|
|
973
|
+
form.querySelector(".dialog-error").textContent = error.message;
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
dialog.querySelector('input[name="message"]').focus();
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function resourceGuide(type) {
|
|
980
|
+
const definition = state.model.resources[type];
|
|
981
|
+
const guidance = definition?.guidance;
|
|
982
|
+
if (!definition || !guidance) return "";
|
|
983
|
+
const sources = (guidance.sourceResourceIds || [])
|
|
984
|
+
.map((id) => state.resources.find(({ record }) => record.id === id))
|
|
985
|
+
.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
|
+
const sourceLinks = sources.length
|
|
991
|
+
? '<div class="guide-links">' + sources.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '">' + esc(record.title) + '</a>').join("") + '</div>'
|
|
992
|
+
: "";
|
|
993
|
+
const obligationLinks = obligations.length
|
|
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>';
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function setupResourceGuide(main) {
|
|
1003
|
+
const trigger = main.querySelector("#resource-guide-trigger");
|
|
1004
|
+
const guide = main.querySelector("#resource-guide");
|
|
1005
|
+
if (!trigger || !guide) return () => {};
|
|
1006
|
+
const listeners = new AbortController();
|
|
1007
|
+
const options = { signal: listeners.signal };
|
|
1008
|
+
let pinned = false;
|
|
1009
|
+
let hideTimer = null;
|
|
1010
|
+
const cancelHide = () => {
|
|
1011
|
+
if (hideTimer !== null) window.clearTimeout(hideTimer);
|
|
1012
|
+
hideTimer = null;
|
|
1013
|
+
};
|
|
1014
|
+
const position = () => {
|
|
1015
|
+
const pageElement = main.querySelector(".page");
|
|
1016
|
+
const page = pageElement?.getBoundingClientRect();
|
|
1017
|
+
const header = trigger.closest(".page-intro")?.getBoundingClientRect();
|
|
1018
|
+
if (!pageElement || !page || !header) return;
|
|
1019
|
+
const pageStyle = getComputedStyle(pageElement);
|
|
1020
|
+
const paddingLeft = Number.parseFloat(pageStyle.paddingLeft) || 0;
|
|
1021
|
+
const paddingRight = Number.parseFloat(pageStyle.paddingRight) || 0;
|
|
1022
|
+
const left = Math.max(15, page.left + paddingLeft);
|
|
1023
|
+
const contentWidth = page.width - paddingLeft - paddingRight;
|
|
1024
|
+
const top = Math.max(10, Math.min(header.bottom + 10, window.innerHeight - 90));
|
|
1025
|
+
guide.style.left = left + "px";
|
|
1026
|
+
guide.style.top = top + "px";
|
|
1027
|
+
guide.style.width = Math.max(240, Math.min(contentWidth, window.innerWidth - left - 15)) + "px";
|
|
1028
|
+
guide.style.maxHeight = Math.max(120, window.innerHeight - top - 15) + "px";
|
|
1029
|
+
};
|
|
1030
|
+
const show = () => {
|
|
1031
|
+
cancelHide();
|
|
1032
|
+
guide.hidden = false;
|
|
1033
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
1034
|
+
position();
|
|
1035
|
+
};
|
|
1036
|
+
const hide = (force = false) => {
|
|
1037
|
+
cancelHide();
|
|
1038
|
+
if (pinned && !force) return;
|
|
1039
|
+
guide.hidden = true;
|
|
1040
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
1041
|
+
};
|
|
1042
|
+
const scheduleHide = () => {
|
|
1043
|
+
cancelHide();
|
|
1044
|
+
hideTimer = window.setTimeout(() => hide(), 160);
|
|
1045
|
+
};
|
|
1046
|
+
trigger.addEventListener("mouseenter", show, options);
|
|
1047
|
+
trigger.addEventListener("mouseleave", scheduleHide, options);
|
|
1048
|
+
trigger.addEventListener("focus", show, options);
|
|
1049
|
+
trigger.addEventListener("blur", scheduleHide, options);
|
|
1050
|
+
trigger.addEventListener("click", (event) => {
|
|
1051
|
+
event.preventDefault();
|
|
1052
|
+
pinned = !pinned;
|
|
1053
|
+
if (pinned) show();
|
|
1054
|
+
else hide(true);
|
|
1055
|
+
}, options);
|
|
1056
|
+
guide.addEventListener("mouseenter", cancelHide, options);
|
|
1057
|
+
guide.addEventListener("mouseleave", scheduleHide, options);
|
|
1058
|
+
guide.addEventListener("focusin", cancelHide, options);
|
|
1059
|
+
guide.addEventListener("focusout", scheduleHide, options);
|
|
1060
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1061
|
+
if (guide.hidden || trigger.contains(event.target) || guide.contains(event.target)) return;
|
|
1062
|
+
pinned = false;
|
|
1063
|
+
hide(true);
|
|
1064
|
+
}, options);
|
|
1065
|
+
document.addEventListener("keydown", (event) => {
|
|
1066
|
+
if (event.key !== "Escape" || guide.hidden) return;
|
|
1067
|
+
pinned = false;
|
|
1068
|
+
hide(true);
|
|
1069
|
+
trigger.focus({ preventScroll: true });
|
|
1070
|
+
}, options);
|
|
1071
|
+
window.addEventListener("resize", position, options);
|
|
1072
|
+
return () => {
|
|
1073
|
+
cancelHide();
|
|
1074
|
+
listeners.abort();
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function rendererSettingsEntry() {
|
|
1079
|
+
return state.resources.find(({ record }) => record.type === "renderer-settings");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function requestOnboarding() {
|
|
1083
|
+
if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
|
|
1084
|
+
if (parseRoute().name !== "home") {
|
|
1085
|
+
history.replaceState(null, "", "#/");
|
|
1086
|
+
render();
|
|
1087
|
+
}
|
|
1088
|
+
onboardingStep = 0;
|
|
1089
|
+
onboardingDraft = initialOnboardingDraft();
|
|
1090
|
+
onboardingBusy = false;
|
|
1091
|
+
onboardingShade = document.createElement("div");
|
|
1092
|
+
onboardingShade.className = "onboarding-shade";
|
|
1093
|
+
onboardingShade.setAttribute("aria-hidden", "true");
|
|
1094
|
+
onboardingShade.innerHTML = "<span></span><span></span><span></span><span></span>";
|
|
1095
|
+
document.body.append(onboardingShade);
|
|
1096
|
+
onboardingDialog = document.createElement("dialog");
|
|
1097
|
+
onboardingDialog.className = "onboarding-dialog";
|
|
1098
|
+
onboardingDialog.setAttribute("aria-labelledby", "onboarding-title");
|
|
1099
|
+
document.body.append(onboardingDialog);
|
|
1100
|
+
onboardingDialog.showModal();
|
|
1101
|
+
onboardingDialog.addEventListener("cancel", (event) => {
|
|
1102
|
+
event.preventDefault();
|
|
1103
|
+
cancelOnboarding();
|
|
1104
|
+
});
|
|
1105
|
+
onboardingDialog.addEventListener("close", () => {
|
|
1106
|
+
clearOnboardingFocus();
|
|
1107
|
+
onboardingShade?.remove();
|
|
1108
|
+
onboardingShade = null;
|
|
1109
|
+
onboardingDialog.remove();
|
|
1110
|
+
onboardingDialog = null;
|
|
1111
|
+
onboardingDraft = null;
|
|
1112
|
+
onboardingBusy = false;
|
|
1113
|
+
});
|
|
1114
|
+
renderOnboardingStep();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function initialOnboardingDraft() {
|
|
1118
|
+
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
|
+
const owner = resourcesOfType("person").find(({ record }) => record.status === "active")?.record;
|
|
1123
|
+
const independentApprover = resourcesOfType("person").find(({ record }) => record.id === "person-independent-approver")?.record;
|
|
1124
|
+
return {
|
|
1125
|
+
systemId: systemEntry?.record.id || "",
|
|
1126
|
+
auditId: auditEntry?.record.id || "",
|
|
1127
|
+
serviceName: systemEntry?.record.title || "",
|
|
1128
|
+
scope: systemEntry?.record.description || "",
|
|
1129
|
+
ownerId: systemEntry?.record.ownerIds?.[0] || auditEntry?.record.ownerIds?.[0] || owner?.id || "",
|
|
1130
|
+
criticality: systemEntry?.record.criticality || "high",
|
|
1131
|
+
dataClassification: systemEntry?.record.dataClassification || "Confidential",
|
|
1132
|
+
internetExposed: systemEntry?.record.internetExposed === false ? "false" : "true",
|
|
1133
|
+
auditGoal: auditGoalFromKind(auditEntry?.record.auditKind),
|
|
1134
|
+
independentApproverName: independentApprover?.title === "Independent Approver" ? "" : independentApprover?.title || "",
|
|
1135
|
+
independentApproverEmail: independentApprover?.email || ""
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function onboardingSteps() {
|
|
1140
|
+
return [
|
|
1141
|
+
{
|
|
1142
|
+
target: null,
|
|
1143
|
+
kicker: "SOC 2 basics",
|
|
1144
|
+
title: "SOC 2 is an audit away",
|
|
1145
|
+
body: [
|
|
1146
|
+
"SOC 2 is just an auditor's report that you have adopted a set of policies meeting certain criteria.",
|
|
1147
|
+
"This includes information security criteria, but it can optionally include availability, processing integrity, confidentiality, and privacy criteria. Generally, customers requesting a SOC 2 report just need the information security criteria."
|
|
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
|
+
}
|
|
1227
|
+
];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function renderOnboardingStep() {
|
|
1231
|
+
if (!onboardingDialog) return;
|
|
1232
|
+
const steps = onboardingSteps();
|
|
1233
|
+
const step = steps[onboardingStep];
|
|
1234
|
+
clearOnboardingFocus();
|
|
1235
|
+
const progress = steps.map((_, index) => '<span class="' + (index <= onboardingStep ? "active" : "") + '"></span>').join("");
|
|
1236
|
+
const explanation = step.sections
|
|
1237
|
+
? '<div class="onboarding-sections">' + step.sections.map((section) => '<section><strong>' + esc(section.title) + '</strong><p>' + esc(section.body) + '</p></section>').join("") + '</div>'
|
|
1238
|
+
: step.points ? '<ul class="onboarding-points">' + step.points.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>'
|
|
1239
|
+
: "";
|
|
1240
|
+
const description = (Array.isArray(step.body) ? step.body : [step.body]).map((paragraph) => '<p class="onboarding-body">' + esc(paragraph) + '</p>').join("");
|
|
1241
|
+
const afterSections = step.afterSections ? '<p class="onboarding-body onboarding-after-sections">' + esc(step.afterSections) + '</p>' : "";
|
|
1242
|
+
const body = onboardingStep === steps.length - 1
|
|
1243
|
+
? onboardingSetupForm()
|
|
1244
|
+
: description + explanation + afterSections;
|
|
1245
|
+
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>' : "") + '<button class="button primary" type="button" data-onboarding="next">' + (onboardingStep === steps.length - 1 ? "Save setup" : "Next") + '</button></div>';
|
|
1246
|
+
onboardingDialog.querySelector('[data-onboarding="skip"]').addEventListener("click", cancelOnboarding);
|
|
1247
|
+
onboardingDialog.querySelector('[data-onboarding="back"]')?.addEventListener("click", () => {
|
|
1248
|
+
captureOnboardingForm();
|
|
1249
|
+
onboardingStep -= 1;
|
|
1250
|
+
renderOnboardingStep();
|
|
1251
|
+
});
|
|
1252
|
+
onboardingDialog.querySelector('[data-onboarding="next"]').addEventListener("click", () => {
|
|
1253
|
+
if (onboardingStep === steps.length - 1) saveOnboarding();
|
|
1254
|
+
else {
|
|
1255
|
+
onboardingStep += 1;
|
|
1256
|
+
renderOnboardingStep();
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
const target = onboardingTarget(step);
|
|
1260
|
+
if (target) {
|
|
1261
|
+
target.classList.add("onboarding-focus");
|
|
1262
|
+
const rect = target.getBoundingClientRect();
|
|
1263
|
+
const dialogWidth = onboardingDialog.offsetWidth;
|
|
1264
|
+
const fitsBeside = rect.right + 18 + dialogWidth <= window.innerWidth - 16 || rect.left - dialogWidth - 18 >= 16;
|
|
1265
|
+
target.scrollIntoView({ block: fitsBeside ? "center" : "start" });
|
|
1266
|
+
}
|
|
1267
|
+
requestAnimationFrame(() => {
|
|
1268
|
+
positionOnboardingDialog(target);
|
|
1269
|
+
positionOnboardingShade(target);
|
|
1270
|
+
});
|
|
1271
|
+
(onboardingStep === steps.length - 1
|
|
1272
|
+
? onboardingDialog.querySelector('input[name="serviceName"]')
|
|
1273
|
+
: onboardingDialog.querySelector('[data-onboarding="next"]'))?.focus();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function onboardingSetupForm() {
|
|
1277
|
+
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
|
+
const classifications = Object.keys(state.workspace.classificationDefinitions || {});
|
|
1283
|
+
if (onboardingDraft.dataClassification && !classifications.includes(onboardingDraft.dataClassification)) {
|
|
1284
|
+
classifications.push(onboardingDraft.dataClassification);
|
|
1285
|
+
}
|
|
1286
|
+
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
|
+
const existing = [
|
|
1289
|
+
currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
|
|
1290
|
+
currentAudit ? "Updates planned audit " + currentAudit.title + " when an audit objective is selected." : ""
|
|
1291
|
+
].filter(Boolean).join(" ");
|
|
1292
|
+
const gitStatus = state.git.available && state.git.remotes?.length
|
|
1293
|
+
? '<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
|
+
: state.git.available
|
|
1295
|
+
? '<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
|
+
: '<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><span>External independent approver</span><input name="independentApproverName" required maxlength="200" value="' + esc(onboardingDraft.independentApproverName) + '" placeholder="Reviewer name"><small>Must be separate from the policy owner and control operators.</small></label><label><span>Approver email</span><input name="independentApproverEmail" type="email" required maxlength="320" value="' + esc(onboardingDraft.independentApproverEmail) + '" placeholder="' + esc(approverEmailPlaceholder) + '"></label><label class="wide"><span>Audit objective</span><select name="auditGoal" required><option value="none" ' + (onboardingDraft.auditGoal === "none" ? "selected" : "") + '>No Engagement Planned</option><option value="readiness" ' + (onboardingDraft.auditGoal === "readiness" ? "selected" : "") + '>Readiness Assessment</option><option value="type-1" ' + (onboardingDraft.auditGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.auditGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>Dates, auditor, subservice method, and final scope stay unset until the engagement is planned.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Saving writes JSON files but does not commit them.</p>';
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function captureOnboardingForm() {
|
|
1301
|
+
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1302
|
+
if (!form) return;
|
|
1303
|
+
const data = new FormData(form);
|
|
1304
|
+
for (const name of ["serviceName", "scope", "ownerId", "criticality", "dataClassification", "internetExposed", "independentApproverName", "independentApproverEmail", "auditGoal"]) {
|
|
1305
|
+
onboardingDraft[name] = String(data.get(name) || "").trim();
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function saveOnboarding() {
|
|
1310
|
+
if (onboardingBusy) return;
|
|
1311
|
+
const form = onboardingDialog?.querySelector("#onboarding-setup");
|
|
1312
|
+
if (!form?.reportValidity()) return;
|
|
1313
|
+
captureOnboardingForm();
|
|
1314
|
+
const policyOwner = state.resources.find(({ record }) => record.id === "person-policy-owner")?.record;
|
|
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…");
|
|
1325
|
+
try {
|
|
1326
|
+
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
|
+
closeOnboarding();
|
|
1419
|
+
history.replaceState(null, "", "#/");
|
|
1420
|
+
render();
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
setOnboardingBusy(false);
|
|
1423
|
+
const errorNode = onboardingDialog?.querySelector(".dialog-error");
|
|
1424
|
+
if (errorNode) errorNode.textContent = error.message;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function cancelOnboarding() {
|
|
1429
|
+
if (!onboardingDialog || onboardingBusy) return;
|
|
1430
|
+
setOnboardingBusy(true, "Skipping…");
|
|
1431
|
+
try {
|
|
1432
|
+
await persistOnboardingPreference(false);
|
|
1433
|
+
closeOnboarding();
|
|
1434
|
+
render();
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
setOnboardingBusy(false);
|
|
1437
|
+
const errorNode = onboardingDialog?.querySelector(".dialog-error");
|
|
1438
|
+
if (errorNode) errorNode.textContent = error.message;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function persistOnboardingPreference(showOnboarding) {
|
|
1443
|
+
const entry = rendererSettingsEntry();
|
|
1444
|
+
if (!entry) throw new Error("Renderer settings are unavailable.");
|
|
1445
|
+
await writeOnboardingResource({ ...entry.record, showOnboarding }, entry);
|
|
1446
|
+
state = await fetchJson("/api/state");
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function writeOnboardingResource(record, entry) {
|
|
1450
|
+
const url = entry
|
|
1451
|
+
? "/api/resource/" + encodeURIComponent(record.type) + "/" + encodeURIComponent(record.id)
|
|
1452
|
+
: "/api/resources";
|
|
1453
|
+
const response = await localFetch(url, {
|
|
1454
|
+
method: entry ? "PUT" : "POST",
|
|
1455
|
+
headers: { "content-type": "application/json" },
|
|
1456
|
+
body: JSON.stringify({ record, revision: entry?.revision })
|
|
1457
|
+
});
|
|
1458
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function setOnboardingBusy(busy, label = "") {
|
|
1462
|
+
if (!onboardingDialog) return;
|
|
1463
|
+
onboardingBusy = busy;
|
|
1464
|
+
onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
|
|
1465
|
+
const next = onboardingDialog.querySelector('[data-onboarding="next"]');
|
|
1466
|
+
if (next && label) next.textContent = label;
|
|
1467
|
+
const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
|
|
1468
|
+
if (skip && label && onboardingStep !== onboardingSteps().length - 1) skip.textContent = label;
|
|
1469
|
+
if (!busy) renderOnboardingStep();
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function positionOnboardingDialog(target) {
|
|
1473
|
+
if (!onboardingDialog) return;
|
|
1474
|
+
const viewportPadding = 16;
|
|
1475
|
+
const gap = 18;
|
|
1476
|
+
const width = onboardingDialog.offsetWidth;
|
|
1477
|
+
const height = onboardingDialog.offsetHeight;
|
|
1478
|
+
let left = Math.max(viewportPadding, (window.innerWidth - width) / 2);
|
|
1479
|
+
let top = Math.max(viewportPadding, (window.innerHeight - height) / 2);
|
|
1480
|
+
if (target) {
|
|
1481
|
+
const rect = target.getBoundingClientRect();
|
|
1482
|
+
const right = rect.right + gap;
|
|
1483
|
+
const leftSide = rect.left - width - gap;
|
|
1484
|
+
const below = rect.bottom + gap;
|
|
1485
|
+
const above = rect.top - height - gap;
|
|
1486
|
+
const beside = right + width <= window.innerWidth - viewportPadding || leftSide >= viewportPadding;
|
|
1487
|
+
if (right + width <= window.innerWidth - viewportPadding) left = right;
|
|
1488
|
+
else if (leftSide >= viewportPadding) left = leftSide;
|
|
1489
|
+
else {
|
|
1490
|
+
left = Math.min(
|
|
1491
|
+
Math.max(viewportPadding, rect.left + (rect.width - width) / 2),
|
|
1492
|
+
Math.max(viewportPadding, window.innerWidth - width - viewportPadding)
|
|
1493
|
+
);
|
|
1494
|
+
if (below + height <= window.innerHeight - viewportPadding) top = below;
|
|
1495
|
+
else if (above >= viewportPadding) top = above;
|
|
1496
|
+
}
|
|
1497
|
+
if (beside) {
|
|
1498
|
+
top = Math.min(Math.max(viewportPadding, rect.top), Math.max(viewportPadding, window.innerHeight - height - viewportPadding));
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
onboardingDialog.style.left = Math.round(left) + "px";
|
|
1502
|
+
onboardingDialog.style.top = Math.round(top) + "px";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function positionCurrentOnboarding() {
|
|
1506
|
+
if (!onboardingDialog) return;
|
|
1507
|
+
const target = onboardingTarget(onboardingSteps()[onboardingStep]);
|
|
1508
|
+
positionOnboardingDialog(target);
|
|
1509
|
+
positionOnboardingShade(target);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function onboardingTarget(step) {
|
|
1513
|
+
const selectors = Array.isArray(step?.target) ? step.target : [step?.target];
|
|
1514
|
+
for (const selector of selectors.filter(Boolean)) {
|
|
1515
|
+
const target = root.querySelector(selector);
|
|
1516
|
+
if (!target) continue;
|
|
1517
|
+
const style = getComputedStyle(target);
|
|
1518
|
+
const rect = target.getBoundingClientRect();
|
|
1519
|
+
if (style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0) return target;
|
|
1520
|
+
}
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function positionOnboardingShade(target) {
|
|
1525
|
+
if (!onboardingShade) return;
|
|
1526
|
+
const panels = [...onboardingShade.children];
|
|
1527
|
+
const viewportWidth = window.innerWidth;
|
|
1528
|
+
const viewportHeight = window.innerHeight;
|
|
1529
|
+
if (!target) {
|
|
1530
|
+
setShadePanel(panels[0], 0, 0, viewportWidth, viewportHeight);
|
|
1531
|
+
panels.slice(1).forEach((panel) => setShadePanel(panel, 0, 0, 0, 0));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const gap = 11;
|
|
1535
|
+
const rect = target.getBoundingClientRect();
|
|
1536
|
+
const holeLeft = Math.max(0, Math.min(viewportWidth, rect.left - gap));
|
|
1537
|
+
const holeTop = Math.max(0, Math.min(viewportHeight, rect.top - gap));
|
|
1538
|
+
const holeRight = Math.max(holeLeft, Math.min(viewportWidth, rect.right + gap));
|
|
1539
|
+
const holeBottom = Math.max(holeTop, Math.min(viewportHeight, rect.bottom + gap));
|
|
1540
|
+
setShadePanel(panels[0], 0, 0, viewportWidth, holeTop);
|
|
1541
|
+
setShadePanel(panels[1], 0, holeBottom, viewportWidth, viewportHeight - holeBottom);
|
|
1542
|
+
setShadePanel(panels[2], 0, holeTop, holeLeft, holeBottom - holeTop);
|
|
1543
|
+
setShadePanel(panels[3], holeRight, holeTop, viewportWidth - holeRight, holeBottom - holeTop);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function setShadePanel(panel, left, top, width, height) {
|
|
1547
|
+
Object.assign(panel.style, {
|
|
1548
|
+
left: Math.round(left) + "px",
|
|
1549
|
+
top: Math.round(top) + "px",
|
|
1550
|
+
width: Math.max(0, Math.round(width)) + "px",
|
|
1551
|
+
height: Math.max(0, Math.round(height)) + "px"
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function clearOnboardingFocus() {
|
|
1556
|
+
root.querySelectorAll(".onboarding-focus").forEach((element) => element.classList.remove("onboarding-focus"));
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function closeOnboarding() {
|
|
1560
|
+
if (!onboardingDialog) return;
|
|
1561
|
+
onboardingBusy = false;
|
|
1562
|
+
clearOnboardingFocus();
|
|
1563
|
+
onboardingDialog.close();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function auditGoalFromKind(kind) {
|
|
1567
|
+
if (kind === "soc-2-type-1") return "type-1";
|
|
1568
|
+
if (kind === "soc-2-type-2") return "type-2";
|
|
1569
|
+
if (kind === "readiness") return "readiness";
|
|
1570
|
+
return "none";
|
|
1571
|
+
}
|
|
1572
|
+
|
|
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
|
+
function openEditor(type, entry = null, options = {}) {
|
|
1582
|
+
const definition = state.model.resources[type];
|
|
1583
|
+
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
1584
|
+
const record = structuredClone(entry?.record || seedRecord(type, definition));
|
|
1585
|
+
const markdownDefinitions = dedicatedMarkdownDefinitions(type);
|
|
1586
|
+
if (!entry && options.seed) {
|
|
1587
|
+
Object.assign(record, options.seed);
|
|
1588
|
+
record.id = createResourceId(type, record.title, state.resources.map(({ record: existing }) => existing.id));
|
|
1589
|
+
}
|
|
1590
|
+
const required = new Set([
|
|
1591
|
+
...Object.entries(state.model.commonFields).filter(([, field]) => field.required).map(([name]) => name),
|
|
1592
|
+
...(definition.required || [])
|
|
1593
|
+
]);
|
|
1594
|
+
const oneOf = new Set((definition.oneOf || []).flat().filter((name) => !name.startsWith("$markdown:")));
|
|
1595
|
+
const names = [...new Set([
|
|
1596
|
+
"title",
|
|
1597
|
+
...required,
|
|
1598
|
+
...(definition.listFields || []),
|
|
1599
|
+
...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
|
|
1600
|
+
...oneOf
|
|
1601
|
+
])].filter((name) => !["schemaVersion", "id", "type"].includes(name) && fields[name]);
|
|
1602
|
+
const dialog = document.createElement("dialog");
|
|
1603
|
+
dialog.className = "editor";
|
|
1604
|
+
dialog.setAttribute("aria-labelledby", "resource-editor-title");
|
|
1605
|
+
const activeMarkdown = markdownDefinitions.filter((markdown) => (
|
|
1606
|
+
markdown.required || markdown.oneOf || !entry || entry.content?.[markdown.name]
|
|
1607
|
+
));
|
|
1608
|
+
const recordContent = recordContentDefinition(type);
|
|
1609
|
+
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>' +
|
|
1611
|
+
activeMarkdown.map((markdown) => {
|
|
1612
|
+
const generated = !entry?.content?.[markdown.name];
|
|
1613
|
+
const source = entry?.content?.[markdown.name]?.source ?? "# " + (record.title || "New " + definition.title) + "\n\nDescribe this " + definition.title.toLowerCase() + " here.\n";
|
|
1614
|
+
const requiredMark = markdown.required ? '<span class="required-mark">Required</span>' : markdown.oneOf ? '<span class="required-mark">One Required</span>' : "";
|
|
1615
|
+
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" ' + (markdown.required ? "required" : "") + '>' + esc(source) + '</textarea></label>';
|
|
1616
|
+
}).join("") + renderRecordContentEditor(type, entry, options) +
|
|
1617
|
+
'<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
|
+
document.body.append(dialog);
|
|
1619
|
+
dialog.showModal();
|
|
1620
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1621
|
+
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
1622
|
+
wireEditorRequirements(dialog, record, fields, definition.oneOf || []);
|
|
1623
|
+
dialog.querySelector(".advanced-editor textarea").addEventListener("input", () => {
|
|
1624
|
+
dialog.dataset.jsonDirty = "true";
|
|
1625
|
+
dialog.querySelector("form").noValidate = true;
|
|
1626
|
+
});
|
|
1627
|
+
if (!entry) {
|
|
1628
|
+
const titleInput = dialog.querySelector('[data-field-group="title"] input');
|
|
1629
|
+
let previousTitle = record.title;
|
|
1630
|
+
titleInput?.addEventListener("input", () => {
|
|
1631
|
+
const nextTitle = titleInput.value;
|
|
1632
|
+
const nextId = createResourceId(type, nextTitle, state.resources.map(({ record }) => record.id));
|
|
1633
|
+
const previousHeading = "# " + (previousTitle || "New " + definition.title);
|
|
1634
|
+
const nextHeading = "# " + (nextTitle || "New " + definition.title);
|
|
1635
|
+
dialog.querySelectorAll('[data-generated-content="true"]').forEach((textarea) => {
|
|
1636
|
+
if (textarea.value.startsWith(previousHeading + "\n")) {
|
|
1637
|
+
textarea.value = nextHeading + textarea.value.slice(previousHeading.length);
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
record.id = nextId;
|
|
1641
|
+
record.title = nextTitle;
|
|
1642
|
+
dialog.querySelector(".advanced-editor textarea").value = JSON.stringify(record, null, 2);
|
|
1643
|
+
previousTitle = nextTitle;
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
1647
|
+
event.preventDefault();
|
|
1648
|
+
try {
|
|
1649
|
+
const advanced = dialog.dataset.jsonDirty === "true";
|
|
1650
|
+
if (!advanced && !dialog.querySelector("form").reportValidity()) return;
|
|
1651
|
+
const updated = advanced
|
|
1652
|
+
? JSON.parse(dialog.querySelector(".advanced-editor textarea").value)
|
|
1653
|
+
: readGuidedRecord(dialog, record, fields);
|
|
1654
|
+
const content = {};
|
|
1655
|
+
dialog.querySelectorAll("[data-markdown-slot]").forEach((textarea) => {
|
|
1656
|
+
const markdown = markdownDefinitions.find(({ name }) => name === textarea.dataset.markdownSlot);
|
|
1657
|
+
const existing = entry?.content?.[markdown.name];
|
|
1658
|
+
if (textarea.value.trim() || existing || markdown.required) {
|
|
1659
|
+
const path = markdownPathFor(updated.type, updated.id, markdown.name);
|
|
1660
|
+
content[path] = textarea.value;
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
const recordContentSource = dialog.querySelector("[data-record-content]");
|
|
1664
|
+
if (recordContent && recordContentSource) {
|
|
1665
|
+
const existing = entry?.content?.[recordContent.slot];
|
|
1666
|
+
if (recordContentSource.value.trim() || existing) {
|
|
1667
|
+
const path = markdownPathFor(updated.type, updated.id, recordContent.slot);
|
|
1668
|
+
content[path] = recordContentSource.value;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
const url = entry
|
|
1672
|
+
? "/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id)
|
|
1673
|
+
: options.obligationCompletion ? "/api/obligation-completions" : "/api/resources";
|
|
1674
|
+
const contentRevisions = Object.fromEntries([
|
|
1675
|
+
...activeMarkdown.map(({ name }) => [entry?.content?.[name]?.path, entry?.content?.[name]?.revision]),
|
|
1676
|
+
[recordContentItem?.path, recordContentItem?.revision]
|
|
1677
|
+
].filter(([path, revision]) => path && revision));
|
|
1678
|
+
const response = await localFetch(url, {
|
|
1679
|
+
method: entry ? "PUT" : "POST",
|
|
1680
|
+
headers: { "content-type": "application/json" },
|
|
1681
|
+
body: JSON.stringify({
|
|
1682
|
+
record: updated,
|
|
1683
|
+
content,
|
|
1684
|
+
revision: entry?.revision || options.obligationCompletion?.revision,
|
|
1685
|
+
contentRevisions,
|
|
1686
|
+
obligationId: options.obligationCompletion?.obligationId
|
|
1687
|
+
})
|
|
1688
|
+
});
|
|
1689
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1690
|
+
state = await fetchJson("/api/state");
|
|
1691
|
+
dialog.close();
|
|
1692
|
+
location.hash = "#/resource/" + encodeURIComponent(updated.type) + "/" + encodeURIComponent(updated.id);
|
|
1693
|
+
render();
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function seedRecord(type, definition) {
|
|
1701
|
+
const record = { schemaVersion: 1, id: createResourceId(type, "new", state.resources.map(({ record }) => record.id)), type, title: "" };
|
|
1702
|
+
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
1703
|
+
for (const name of definition.required || []) {
|
|
1704
|
+
const field = fields[name];
|
|
1705
|
+
if (record[name] !== undefined) continue;
|
|
1706
|
+
if (field.relation) {
|
|
1707
|
+
const candidates = relationCandidates(field);
|
|
1708
|
+
record[name] = field.type === "array" ? (candidates.length === 1 ? [candidates[0].record.id] : []) : (candidates.length === 1 ? candidates[0].record.id : "");
|
|
1709
|
+
}
|
|
1710
|
+
else if (field.type === "array") record[name] = [];
|
|
1711
|
+
else if (field.type === "object") record[name] = {};
|
|
1712
|
+
else if (field.type === "boolean") record[name] = false;
|
|
1713
|
+
else if (field.type === "integer" || field.type === "number") record[name] = 0;
|
|
1714
|
+
else if (field.values?.length) record[name] = field.values[0];
|
|
1715
|
+
else record[name] = "";
|
|
1716
|
+
}
|
|
1717
|
+
for (const choices of definition.oneOf || []) {
|
|
1718
|
+
if (choices.some((name) => record[name] !== undefined)) continue;
|
|
1719
|
+
const name = choices.find((candidate) => fields[candidate]);
|
|
1720
|
+
if (name) record[name] = fields[name].type === "array" ? [] : "";
|
|
1721
|
+
}
|
|
1722
|
+
return record;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function dedicatedMarkdownDefinitions(type) {
|
|
1726
|
+
const definition = state.model.resources[type];
|
|
1727
|
+
const choices = new Set((definition.oneOf || []).flat().filter((name) => name.startsWith("$markdown:")).map((name) => name.slice("$markdown:".length)));
|
|
1728
|
+
return Object.entries(definition.markdown || {}).map(([name, markdown]) => ({
|
|
1729
|
+
name,
|
|
1730
|
+
label: markdown.label || humanize(name),
|
|
1731
|
+
primary: Boolean(markdown.primary),
|
|
1732
|
+
required: Boolean(markdown.required),
|
|
1733
|
+
oneOf: choices.has(name)
|
|
1734
|
+
}));
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function markdownPathFor(type, id, name) {
|
|
1738
|
+
const definition = state.model.resources[type];
|
|
1739
|
+
const markdown = definition.markdown?.[name];
|
|
1740
|
+
const primary = markdown ? Boolean(markdown.primary) : !definition.markdown && name === state.model.recordContent.slot;
|
|
1741
|
+
const recordPath = definition.singleton || definition.collection + "/" + (definition.recordPath || "{id}.json").replaceAll("{id}", id);
|
|
1742
|
+
const slash = recordPath.lastIndexOf("/");
|
|
1743
|
+
const directory = slash === -1 ? "" : recordPath.slice(0, slash + 1);
|
|
1744
|
+
const filename = recordPath.slice(slash + 1).replace(/\.json$/, "");
|
|
1745
|
+
const suffix = primary ? "" : "-" + name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
1746
|
+
return directory + filename + suffix + ".md";
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function renderRecordContentEditor(type, entry, options) {
|
|
1750
|
+
const config = recordContentDefinition(type);
|
|
1751
|
+
if (!config) return "";
|
|
1752
|
+
const item = entry?.content?.[config.slot];
|
|
1753
|
+
const source = item?.source || "";
|
|
1754
|
+
const editor = '<label class="content-editor-field record-content-editor"><span>' + esc(config.label) + ' Markdown <small>optional</small></span><textarea data-record-content spellcheck="true" placeholder="Document the work performed, method, results, decisions, and follow-up.">' + esc(source) + '</textarea></label>';
|
|
1755
|
+
if (config.mode === "default") return editor;
|
|
1756
|
+
const open = item || options.addRecordContent;
|
|
1757
|
+
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
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function editorField(type, name, field, value, required, editing, oneOfRequired = false) {
|
|
1761
|
+
const label = fieldLabel(type, name);
|
|
1762
|
+
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
1763
|
+
? '<span class="required-mark" ' + (required || oneOfRequired ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
1764
|
+
: "";
|
|
1765
|
+
const help = name === "title"
|
|
1766
|
+
? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
|
|
1767
|
+
: field.relation ? relationHelp(field)
|
|
1768
|
+
: "";
|
|
1769
|
+
let control;
|
|
1770
|
+
if (field.relation && field.type === "array") {
|
|
1771
|
+
const candidates = relationCandidates(field);
|
|
1772
|
+
control = candidates.length
|
|
1773
|
+
? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(record.id) + '</small></span></label>').join("") + '</div>'
|
|
1774
|
+
: required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
|
|
1775
|
+
return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
|
|
1776
|
+
}
|
|
1777
|
+
if (field.relation) {
|
|
1778
|
+
const candidates = relationCandidates(field);
|
|
1779
|
+
control = candidates.length
|
|
1780
|
+
? '<select><option value="">Select a Resource</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(record.id) + '</option>').join("") + '</select>'
|
|
1781
|
+
: required ? '<select><option value="">No Matching Resources Exist Yet</option></select>' : '<div class="missing-options">No matching resources exist yet.</div>';
|
|
1782
|
+
return fieldWrap(name, "relation", label, requiredMark, control, help, required);
|
|
1783
|
+
}
|
|
1784
|
+
if (field.type === "enum" || field.type === "rating" || field.type === "outcome") {
|
|
1785
|
+
const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || [];
|
|
1786
|
+
control = '<select><option value="">Select</option>' + values.map((item) => '<option value="' + esc(item) + '" ' + (value === item ? "selected" : "") + '>' + esc(properCase(item)) + '</option>').join("") + '</select>';
|
|
1787
|
+
return fieldWrap(name, "string", label, requiredMark, control, help, required);
|
|
1788
|
+
}
|
|
1789
|
+
if (field.type === "boolean") {
|
|
1790
|
+
control = '<select><option value="">Not Set</option><option value="true" ' + (value === true ? "selected" : "") + '>Yes</option><option value="false" ' + (value === false ? "selected" : "") + '>No</option></select>';
|
|
1791
|
+
return fieldWrap(name, "boolean", label, requiredMark, control, help, required);
|
|
1792
|
+
}
|
|
1793
|
+
if (field.type === "object") {
|
|
1794
|
+
control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
1795
|
+
return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
|
|
1796
|
+
}
|
|
1797
|
+
if (field.type === "array") {
|
|
1798
|
+
control = '<textarea placeholder="One value per line">' + esc((value || []).join("\n")) + '</textarea>';
|
|
1799
|
+
return fieldWrap(name, "array", label, requiredMark, control, "One value per line", required);
|
|
1800
|
+
}
|
|
1801
|
+
if (["description", "statement", "scope", "rationale", "purpose"].some((part) => name.toLowerCase().includes(part))) {
|
|
1802
|
+
control = '<textarea>' + esc(value ?? "") + '</textarea>';
|
|
1803
|
+
} else {
|
|
1804
|
+
const inputType = field.type === "date" ? "date" : field.type === "number" || field.type === "integer" ? "number" : field.format === "email" ? "email" : "text";
|
|
1805
|
+
const placeholder = name === "title" && !editing ? ' placeholder="Enter ' + esc(label.toLowerCase()) + '"' : "";
|
|
1806
|
+
const minimum = field.minimum !== undefined ? ' min="' + esc(field.minimum) + '"' : "";
|
|
1807
|
+
const maximum = field.maximum !== undefined ? ' max="' + esc(field.maximum) + '"' : "";
|
|
1808
|
+
control = '<input type="' + inputType + '" value="' + esc(value ?? "") + '"' + placeholder + minimum + maximum + '>';
|
|
1809
|
+
}
|
|
1810
|
+
return fieldWrap(name, field.type, label, requiredMark, control, help, required);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function fieldWrap(name, kind, label, requiredMark, control, help, required) {
|
|
1814
|
+
const labelId = "field-label-" + name;
|
|
1815
|
+
let labelledControl = control.replace(/^<([a-z]+)/, '<$1 aria-labelledby="' + esc(labelId) + '"');
|
|
1816
|
+
if (required && /^(input|select|textarea)$/.test(labelledControl.match(/^<([a-z]+)/)?.[1] || "")) {
|
|
1817
|
+
labelledControl = labelledControl.replace(/^<([a-z]+)/, "<$1 required");
|
|
1818
|
+
}
|
|
1819
|
+
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
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function wireEditorRequirements(dialog, base, fields, oneOfGroups) {
|
|
1823
|
+
const refreshGroup = (group, required) => {
|
|
1824
|
+
group.dataset.required = required ? "true" : "false";
|
|
1825
|
+
const mark = group.querySelector(".required-mark");
|
|
1826
|
+
if (mark) mark.hidden = !required;
|
|
1827
|
+
const checkboxes = [...group.querySelectorAll('input[type="checkbox"]')];
|
|
1828
|
+
if (checkboxes.length) {
|
|
1829
|
+
const hasSelection = checkboxes.some((checkbox) => checkbox.checked);
|
|
1830
|
+
const requiredIndex = hasSelection ? checkboxes.findIndex((checkbox) => checkbox.checked) : 0;
|
|
1831
|
+
checkboxes.forEach((checkbox, index) => { checkbox.required = required && index === requiredIndex; });
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const control = group.querySelector("input,select,textarea");
|
|
1835
|
+
if (control) control.required = required;
|
|
1836
|
+
};
|
|
1837
|
+
const currentValue = (name) => {
|
|
1838
|
+
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
1839
|
+
if (markdownName) return dialog.querySelector('[data-markdown-slot="' + CSS.escape(markdownName) + '"]')?.value;
|
|
1840
|
+
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
1841
|
+
if (!group) return base[name];
|
|
1842
|
+
if (group.dataset.kind === "relation-array") {
|
|
1843
|
+
return [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
|
|
1844
|
+
}
|
|
1845
|
+
const control = group.querySelector("input,select,textarea");
|
|
1846
|
+
if (!control) return base[name];
|
|
1847
|
+
if (group.dataset.kind === "boolean") return control.value === "" ? undefined : control.value === "true";
|
|
1848
|
+
if (group.dataset.kind === "integer" || group.dataset.kind === "number") return control.value === "" ? undefined : Number(control.value);
|
|
1849
|
+
return control.value;
|
|
1850
|
+
};
|
|
1851
|
+
const refresh = () => {
|
|
1852
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1853
|
+
if (!field.requiredWhen) continue;
|
|
1854
|
+
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
1855
|
+
if (!group) continue;
|
|
1856
|
+
const required = Object.entries(field.requiredWhen).every(([conditionName, expected]) => currentValue(conditionName) === expected);
|
|
1857
|
+
refreshGroup(group, required);
|
|
1858
|
+
}
|
|
1859
|
+
for (const group of dialog.querySelectorAll('[data-kind="relation-array"][data-required="true"]')) {
|
|
1860
|
+
refreshGroup(group, true);
|
|
1861
|
+
}
|
|
1862
|
+
for (const names of oneOfGroups) {
|
|
1863
|
+
const choices = names.map((name) => {
|
|
1864
|
+
const markdownName = name.startsWith("$markdown:") ? name.slice("$markdown:".length) : null;
|
|
1865
|
+
const group = dialog.querySelector('[data-field-group="' + CSS.escape(name) + '"]');
|
|
1866
|
+
const value = currentValue(name);
|
|
1867
|
+
const present = Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && String(value).trim() !== "";
|
|
1868
|
+
return { control: markdownName ? dialog.querySelector('[data-markdown-slot="' + CSS.escape(markdownName) + '"]') : group?.querySelector("input,select,textarea"), present };
|
|
1869
|
+
}).filter(({ control }) => control);
|
|
1870
|
+
choices.forEach(({ control }) => {
|
|
1871
|
+
control.required = false;
|
|
1872
|
+
control.setCustomValidity("");
|
|
1873
|
+
});
|
|
1874
|
+
const choice = choices.find(({ present }) => present) || choices[0];
|
|
1875
|
+
if (choice) {
|
|
1876
|
+
choice.control.required = true;
|
|
1877
|
+
choice.control.setCustomValidity(choice.present ? "" : "Provide at least one of: " + names.map((name) => humanize(name.replace(/^\$markdown:/, "") + (name.startsWith("$markdown:") ? " Markdown" : ""))).join(", ") + ".");
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
for (const group of dialog.querySelectorAll("[data-field-group]")) {
|
|
1882
|
+
refreshGroup(group, group.dataset.required === "true");
|
|
1883
|
+
}
|
|
1884
|
+
dialog.querySelector("form").addEventListener("input", refresh);
|
|
1885
|
+
dialog.querySelector("form").addEventListener("change", refresh);
|
|
1886
|
+
refresh();
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function readGuidedRecord(dialog, base, fields) {
|
|
1890
|
+
const record = structuredClone(base);
|
|
1891
|
+
for (const group of dialog.querySelectorAll("[data-field-group]")) {
|
|
1892
|
+
const name = group.dataset.fieldGroup;
|
|
1893
|
+
const kind = group.dataset.kind;
|
|
1894
|
+
let value;
|
|
1895
|
+
if (kind === "relation-array") value = [...group.querySelectorAll('input[type="checkbox"]:checked')].map((input) => input.value);
|
|
1896
|
+
else {
|
|
1897
|
+
const control = group.querySelector("input,select,textarea");
|
|
1898
|
+
const raw = control?.value ?? "";
|
|
1899
|
+
if (kind === "array") value = raw.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
|
1900
|
+
else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
|
|
1901
|
+
else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
|
|
1902
|
+
else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
|
|
1903
|
+
else if (kind === "number") value = raw === "" ? undefined : Number(raw);
|
|
1904
|
+
else value = raw;
|
|
1905
|
+
}
|
|
1906
|
+
if ((value === "" || value === undefined || (Array.isArray(value) && !value.length)) && group.dataset.required !== "true") delete record[name];
|
|
1907
|
+
else record[name] = value;
|
|
1908
|
+
}
|
|
1909
|
+
return record;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function relationCandidates(field) {
|
|
1913
|
+
return state.resources.filter(({ record }) => field.relation.includes("*") || field.relation.includes(record.type));
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function relationHelp(field) {
|
|
1917
|
+
return "References " + (field.relation.includes("*") ? "any resource" : field.relation.map((type) => state.model.resources[type]?.pluralTitle || type).join(" or "));
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function openContentEditor(entry, name) {
|
|
1921
|
+
const item = entry.content[name];
|
|
1922
|
+
if (!item) return;
|
|
1923
|
+
const dialog = document.createElement("dialog");
|
|
1924
|
+
dialog.className = "editor content-dialog";
|
|
1925
|
+
dialog.setAttribute("aria-labelledby", "content-editor-title");
|
|
1926
|
+
dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
|
|
1927
|
+
document.body.append(dialog);
|
|
1928
|
+
dialog.showModal();
|
|
1929
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
1930
|
+
dialog.querySelector("#save-content").addEventListener("click", async () => {
|
|
1931
|
+
try {
|
|
1932
|
+
const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision }) });
|
|
1933
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
1934
|
+
state = await fetchJson("/api/state");
|
|
1935
|
+
dialog.close();
|
|
1936
|
+
render();
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function bindCommon() {
|
|
1944
|
+
root.querySelectorAll(".nav-heading, .nav-subheading").forEach((button) => button.addEventListener("click", () => {
|
|
1945
|
+
const group = button.closest(".nav-group");
|
|
1946
|
+
const open = group.classList.toggle("open");
|
|
1947
|
+
setNavigationGroupOpen(group.dataset.group, open);
|
|
1948
|
+
button.setAttribute("aria-expanded", String(open));
|
|
1949
|
+
}));
|
|
1950
|
+
const navButton = root.querySelector(".mobile-nav");
|
|
1951
|
+
const sidebar = root.querySelector(".sidebar");
|
|
1952
|
+
const workspace = root.querySelector(".workspace");
|
|
1953
|
+
const setNavigation = (open) => {
|
|
1954
|
+
sidebar.classList.toggle("shown", open);
|
|
1955
|
+
workspace.inert = open;
|
|
1956
|
+
navButton?.setAttribute("aria-expanded", String(open));
|
|
1957
|
+
navButton?.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
|
|
1958
|
+
if (open) sidebar.querySelector(".nav-close")?.focus();
|
|
1959
|
+
};
|
|
1960
|
+
navButton?.addEventListener("click", () => setNavigation(!sidebar.classList.contains("shown")));
|
|
1961
|
+
root.querySelector(".nav-close")?.addEventListener("click", () => {
|
|
1962
|
+
setNavigation(false);
|
|
1963
|
+
navButton?.focus();
|
|
1964
|
+
});
|
|
1965
|
+
root.querySelector(".nav-scrim")?.addEventListener("click", () => {
|
|
1966
|
+
setNavigation(false);
|
|
1967
|
+
navButton?.focus();
|
|
1968
|
+
});
|
|
1969
|
+
const search = root.querySelector("#global-search");
|
|
1970
|
+
search?.addEventListener("keydown", (event) => {
|
|
1971
|
+
if (event.key === "Enter" && search.value.trim()) {
|
|
1972
|
+
event.preventDefault();
|
|
1973
|
+
globalSearch(search.value);
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
document.onkeydown = (event) => {
|
|
1977
|
+
if (event.key === "Escape" && sidebar.classList.contains("shown")) {
|
|
1978
|
+
setNavigation(false);
|
|
1979
|
+
navButton?.focus();
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
if (event.key === "/" && !/input|textarea/i.test(document.activeElement?.tagName)) {
|
|
1983
|
+
event.preventDefault();
|
|
1984
|
+
search?.focus();
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function globalSearch(query) {
|
|
1990
|
+
query = query.trim();
|
|
1991
|
+
const matches = state.resources.filter((entry) => entrySearchText(entry).includes(query.toLowerCase()));
|
|
1992
|
+
let pageNumber = 1;
|
|
1993
|
+
const dialog = document.createElement("dialog");
|
|
1994
|
+
dialog.className = "search-results";
|
|
1995
|
+
dialog.setAttribute("aria-labelledby", "search-results-title");
|
|
1996
|
+
dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">Search</p><h2 id="search-results-title">' + esc(query) + '</h2></div><button class="icon-button" aria-label="Close">×</button></div><div class="result-list"></div><nav class="pagination search-pagination" aria-label="Search result pages" hidden><button class="button" type="button" data-search-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-search-page="next">Next</button></nav>';
|
|
1997
|
+
document.body.append(dialog);
|
|
1998
|
+
dialog.showModal();
|
|
1999
|
+
dialog.querySelector(".icon-button").onclick = () => dialog.close();
|
|
2000
|
+
const results = dialog.querySelector(".result-list");
|
|
2001
|
+
const pagination = dialog.querySelector(".search-pagination");
|
|
2002
|
+
const previous = pagination.querySelector('[data-search-page="previous"]');
|
|
2003
|
+
const next = pagination.querySelector('[data-search-page="next"]');
|
|
2004
|
+
const pageStatus = pagination.querySelector(".page-status");
|
|
2005
|
+
const renderResults = () => {
|
|
2006
|
+
const totalPages = Math.max(1, Math.ceil(matches.length / SEARCH_PAGE_SIZE));
|
|
2007
|
+
const start = (pageNumber - 1) * SEARCH_PAGE_SIZE;
|
|
2008
|
+
const visible = matches.slice(start, start + SEARCH_PAGE_SIZE);
|
|
2009
|
+
results.innerHTML = visible.length ? visible.map(({ record }) => '<a href="#/resource/' + encodeURIComponent(record.type) + '/' + encodeURIComponent(record.id) + '"><strong>' + esc(record.title) + '</strong><small>' + esc(state.model.resources[record.type].title) + '</small></a>').join("") : empty("No matching records.");
|
|
2010
|
+
results.querySelectorAll("a").forEach((link) => link.onclick = () => dialog.close());
|
|
2011
|
+
pagination.hidden = totalPages === 1;
|
|
2012
|
+
previous.disabled = pageNumber === 1;
|
|
2013
|
+
next.disabled = pageNumber === totalPages;
|
|
2014
|
+
const firstVisible = matches.length ? start + 1 : 0;
|
|
2015
|
+
const lastVisible = Math.min(start + SEARCH_PAGE_SIZE, matches.length);
|
|
2016
|
+
pageStatus.textContent = "Page " + pageNumber + " of " + totalPages + " · " + firstVisible + "–" + lastVisible + " of " + matches.length;
|
|
2017
|
+
};
|
|
2018
|
+
previous.addEventListener("click", () => {
|
|
2019
|
+
pageNumber -= 1;
|
|
2020
|
+
renderResults();
|
|
2021
|
+
results.scrollTop = 0;
|
|
2022
|
+
});
|
|
2023
|
+
next.addEventListener("click", () => {
|
|
2024
|
+
pageNumber += 1;
|
|
2025
|
+
renderResults();
|
|
2026
|
+
results.scrollTop = 0;
|
|
2027
|
+
});
|
|
2028
|
+
renderResults();
|
|
2029
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function auditProgress(audit) {
|
|
2033
|
+
const requests = resourcesOfType("audit-request").filter(({ record }) => record.auditId === audit.id);
|
|
2034
|
+
if (!requests.length) {
|
|
2035
|
+
return '<div class="audit-progress-empty"><strong>No audit requests yet</strong><span>Add them when the auditor sends the request list.</span></div>';
|
|
2036
|
+
}
|
|
2037
|
+
const complete = requests.filter(({ record }) => ["complete", "accepted", "closed"].includes(record.status)).length;
|
|
2038
|
+
const percentage = requests.length ? Math.round((complete / requests.length) * 100) : 0;
|
|
2039
|
+
return '<div class="audit-progress"><div class="progress-number"><strong>' + percentage + '%</strong><span>requests complete</span></div><div class="progress"><span style="width:' + percentage + '%"></span></div><div class="progress-meta"><span>' + complete + ' complete</span><span>' + (requests.length - complete) + ' open</span><span>' + requests.length + ' total</span></div></div>';
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function metric(label, value, note, tone) {
|
|
2043
|
+
return '<section class="metric"><div class="metric-label"><span class="status-dot ' + tone + '"></span>' + esc(label) + '</div><strong>' + esc(value) + '</strong><small>' + esc(note) + '</small></section>';
|
|
2044
|
+
}
|
|
2045
|
+
function countOverdue(entries) { const today = currentDate(); return entries.filter(({ record }) => dueDate(record) && dueDate(record) < today).length; }
|
|
2046
|
+
function dueDate(record) {
|
|
2047
|
+
const explicit = record.dueOn || record.nextDueOn || record.reviewDueOn || record.expiresOn || record.acceptanceExpiresOn || record.scheduledOn;
|
|
2048
|
+
if (explicit) return explicit;
|
|
2049
|
+
if (record.type !== "obligation" || record.status !== "active") return null;
|
|
2050
|
+
const recurrence = record.recurrence?.anchorDate
|
|
2051
|
+
? record.recurrence
|
|
2052
|
+
: { ...(record.recurrence || {}), anchorDate: record.startsOn };
|
|
2053
|
+
return nextCalendarOccurrence(recurrence, currentDate());
|
|
2054
|
+
}
|
|
2055
|
+
function currentDate() {
|
|
2056
|
+
try {
|
|
2057
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone: state.workspace.timezone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date());
|
|
2058
|
+
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
2059
|
+
return values.year + "-" + values.month + "-" + values.day;
|
|
2060
|
+
} catch {
|
|
2061
|
+
return new Date().toISOString().slice(0, 10);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
function currentLocalDateTime() {
|
|
2065
|
+
const now = new Date();
|
|
2066
|
+
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
|
2067
|
+
return local.toISOString().slice(0, 16);
|
|
2068
|
+
}
|
|
2069
|
+
function formatCadence(value) {
|
|
2070
|
+
if (!value || typeof value !== "object") return "";
|
|
2071
|
+
if (value.mode === "calendar" && Number.isInteger(value.interval) && value.interval > 0 && value.unit) {
|
|
2072
|
+
return value.interval === 1 ? "Every " + value.unit : "Every " + value.interval + " " + value.unit + "s";
|
|
2073
|
+
}
|
|
2074
|
+
return value.mode ? humanize(value.mode) : "";
|
|
2075
|
+
}
|
|
2076
|
+
function resourcesOfType(type) { return state.resources.filter(({ record }) => record.type === type); }
|
|
2077
|
+
function searchText(record) {
|
|
2078
|
+
const definition = state.model.resources[record.type];
|
|
2079
|
+
if (!definition) return "";
|
|
2080
|
+
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
2081
|
+
const values = [record.id, record.type];
|
|
2082
|
+
Object.entries(fields).forEach(([name, field]) => {
|
|
2083
|
+
if (!field.search || record[name] === undefined) return;
|
|
2084
|
+
values.push(...(Array.isArray(record[name]) ? record[name] : [record[name]]));
|
|
2085
|
+
});
|
|
2086
|
+
return values.map(String).join(" ").toLowerCase();
|
|
2087
|
+
}
|
|
2088
|
+
function entrySearchText(entry) {
|
|
2089
|
+
return (searchText(entry.record) + " " + Object.values(entry.content || {}).map((item) => item.source || "").join(" ")).toLowerCase();
|
|
2090
|
+
}
|
|
2091
|
+
function readNavigationGroupState() {
|
|
2092
|
+
try {
|
|
2093
|
+
const value = JSON.parse(window.localStorage.getItem(NAV_GROUP_STORAGE_KEY) || "{}");
|
|
2094
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
2095
|
+
return Object.fromEntries(Object.entries(value).filter(([, open]) => typeof open === "boolean"));
|
|
2096
|
+
} catch {
|
|
2097
|
+
return {};
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
function setNavigationGroupOpen(group, open) {
|
|
2101
|
+
navigationGroupState[group] = open;
|
|
2102
|
+
try {
|
|
2103
|
+
window.localStorage.setItem(NAV_GROUP_STORAGE_KEY, JSON.stringify(navigationGroupState));
|
|
2104
|
+
} catch {
|
|
2105
|
+
// Browser storage may be unavailable; the current page still keeps the state.
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
function groupTitle(id) { return state.model.groups.find((group) => group.id === id)?.title || "Program"; }
|
|
2109
|
+
function fieldDefinition(type, name) { return state.model.resources[type]?.fields?.[name] || state.model.commonFields[name]; }
|
|
2110
|
+
function fieldLabel(type, name) {
|
|
2111
|
+
if (name === "title") return state.model.resources[type]?.titleLabel || state.model.commonFields.title.label;
|
|
2112
|
+
const recordContent = recordContentDefinition(type);
|
|
2113
|
+
if (recordContent && name === recordContent.slot) return recordContent.label;
|
|
2114
|
+
const markdown = dedicatedMarkdownDefinitions(type).find((item) => item.name === name);
|
|
2115
|
+
if (markdown) return markdown.label;
|
|
2116
|
+
return fieldDefinition(type, name)?.label || humanize(name);
|
|
2117
|
+
}
|
|
2118
|
+
function filterOptionLabel(value) { return state.resources.find(({ record }) => record.id === value)?.record.title || properCase(value); }
|
|
2119
|
+
function humanize(value) { return String(value).replace(/[-_]+/g, " ").replace(/Ids?$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (letter) => letter.toUpperCase()); }
|
|
2120
|
+
function properCase(value) { return humanize(value).replace(/\b[a-z]/g, (letter) => letter.toUpperCase()).replace(/\bSoc 2\b/g, "SOC 2"); }
|
|
2121
|
+
function titleCase(value) {
|
|
2122
|
+
const words = String(value).split(/\s+/);
|
|
2123
|
+
return words.map((word, index) => {
|
|
2124
|
+
const bare = word.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "");
|
|
2125
|
+
if ((/[A-Z]/.test(bare) && !/[a-z]/.test(bare)) || /^[A-Z]{2,}[a-z]?$/.test(bare) || /[a-z][A-Z]/.test(bare)) return word;
|
|
2126
|
+
if (index > 0 && index < words.length - 1 && TITLE_CASE_MINOR_WORDS.has(bare.toLowerCase())) return word.toLowerCase();
|
|
2127
|
+
return word.toLowerCase().replace(/(^|[-/])([a-z])/g, (_, boundary, letter) => boundary + letter.toUpperCase());
|
|
2128
|
+
}).join(" ");
|
|
2129
|
+
}
|
|
2130
|
+
function conditionMatches(record, condition) { return Boolean(condition) && Object.entries(condition).every(([name, expected]) => record[name] === expected); }
|
|
2131
|
+
function formatValue(value, field, type) {
|
|
2132
|
+
if (value === undefined || value === null || value === "") return '<span class="muted">Not set</span>';
|
|
2133
|
+
const definition = fieldDefinition(type, field);
|
|
2134
|
+
if (definition?.type === "date") return esc(formatCalendarDate(value));
|
|
2135
|
+
if (definition?.type === "timestamp") return esc(formatLocalDateTime(value));
|
|
2136
|
+
if (field === "status" || field.endsWith("Rating") || field === "severity" || field === "outcome") return '<span class="badge status-' + esc(String(value)) + '">' + esc(String(value)) + '</span>';
|
|
2137
|
+
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
|
+
if (field === "sourceReference" && typeof value === "object") {
|
|
2139
|
+
const href = safeExternalUrl(value.url);
|
|
2140
|
+
if (href) return '<a class="external-source" href="' + esc(href) + '" target="_blank" rel="noopener noreferrer"><span><strong>' + esc(value.title || "Official source") + '</strong><small>' + esc(href) + '</small></span><b aria-hidden="true">↗</b></a>';
|
|
2141
|
+
}
|
|
2142
|
+
if (typeof value === "object") return '<pre class="compact-json">' + esc(JSON.stringify(value, null, 2)) + '</pre>';
|
|
2143
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
2144
|
+
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2145
|
+
if (reference) return '<a class="tag relation" href="#/resource/' + encodeURIComponent(reference.record.type) + '/' + encodeURIComponent(reference.record.id) + '">' + esc(reference.record.title) + '</a>';
|
|
2146
|
+
return esc(String(value));
|
|
2147
|
+
}
|
|
2148
|
+
function formatReference(value) {
|
|
2149
|
+
const reference = state.resources.find(({ record }) => record.id === value);
|
|
2150
|
+
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>';
|
|
2151
|
+
}
|
|
2152
|
+
function safeExternalUrl(value) {
|
|
2153
|
+
try {
|
|
2154
|
+
const url = new URL(String(value));
|
|
2155
|
+
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
|
|
2156
|
+
} catch {
|
|
2157
|
+
return "";
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
${createResourceId.toString()}
|
|
2161
|
+
${parseCalendarDate.toString()}
|
|
2162
|
+
${utcCalendarDate.toString()}
|
|
2163
|
+
${formatCalendarDateUtc.toString()}
|
|
2164
|
+
${validCalendarRecurrence.toString()}
|
|
2165
|
+
${calendarOccurrence.toString()}
|
|
2166
|
+
${calendarOccurrenceIndex.toString()}
|
|
2167
|
+
${nextCalendarOccurrence.toString()}
|
|
2168
|
+
${formatCalendarDate.toString()}
|
|
2169
|
+
${formatLocalDateTime.toString()}
|
|
2170
|
+
function empty(message) { return '<div class="empty">' + esc(message) + '</div>'; }
|
|
2171
|
+
function pluralize(noun, count) { return count === 1 ? noun : noun + "s"; }
|
|
2172
|
+
function renderNotFound(main) { main.innerHTML = '<div class="page">' + empty("That resource does not exist.") + '</div>'; }
|
|
2173
|
+
function showError(message) {
|
|
2174
|
+
const dialog = document.createElement("dialog");
|
|
2175
|
+
dialog.className = "alert-dialog";
|
|
2176
|
+
dialog.setAttribute("aria-labelledby", "alert-dialog-title");
|
|
2177
|
+
dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">Could not complete the action</p><h2 id="alert-dialog-title">Review the Record</h2></div><button class="icon-button" aria-label="Close">×</button></div><p>' + esc(message) + '</p><div class="dialog-actions"><button class="button primary">Close</button></div>';
|
|
2178
|
+
document.body.append(dialog);
|
|
2179
|
+
dialog.showModal();
|
|
2180
|
+
dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
2181
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
2182
|
+
}
|
|
2183
|
+
async function responseMessage(response) {
|
|
2184
|
+
const source = await response.text();
|
|
2185
|
+
try { return JSON.parse(source).error || source; } catch { return source; }
|
|
2186
|
+
}
|
|
2187
|
+
async function localFetch(url, options) {
|
|
2188
|
+
try {
|
|
2189
|
+
return await fetch(url, options);
|
|
2190
|
+
} catch {
|
|
2191
|
+
throw new Error("The FileGRC server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
|
|
2195
|
+
function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); }
|
|
2196
|
+
`;
|
|
2197
|
+
|
|
2198
|
+
export const APP_STYLES = String.raw`
|
|
2199
|
+
: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:11px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
|
|
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:11px}.audit-progress-empty span{margin-top:4px;color:var(--muted);font-size:9px}
|
|
2203
|
+
.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
|
+
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
|
+
.topbar{height:63px}
|
|
2206
|
+
.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:8px}.nav-stage-copy,.nav-stage-copy strong,.nav-stage-copy small{display:block;min-width:0}.nav-stage-copy strong{font-size:10px;line-height:1.25}.nav-stage-copy small{margin-top:2px;color:#aeb6d8;font-size:8px;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:8px;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:11px}.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:11px;font-weight:800}.organization-nav strong,.organization-nav small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organization-nav strong{font-size:10px}.organization-nav small{margin-top:2px;color:#aeb6d8;font-size:8px}.organization-arrow{color:#aeb6d8;font-size:16px}.organization-grid{display:grid;grid-template-columns:repeat(3,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:11px}.organization-links small{margin-top:3px;color:var(--muted);font-size:9px;line-height:1.4}.organization-links b{color:var(--accent);font-size:11px}.organization-links a:hover strong{color:var(--accent)}
|
|
2207
|
+
.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:11px}
|
|
2208
|
+
.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:22px;margin:3px 0 2px}.overview-hero p:not(.kicker){font-size:10px}.home-page .readiness-map{padding:12px 15px}.home-page .readiness-map-head{margin-bottom:8px}.home-page .readiness-flow a{padding:7px}
|
|
2210
|
+
.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:26px}.overview-grid .audit-engagement{padding:8px 11px}
|
|
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:10px;min-width:150px;text-align:center}.button:disabled{cursor:not-allowed;opacity:.45}.search-pagination{padding-top:2px}
|
|
2213
|
+
.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:15px}.setup-banner p:not(.kicker){margin:0;color:var(--muted);font-size:11px;line-height:1.5}.setup-banner ol{margin:0;padding-left:22px;display:grid;gap:7px}.setup-banner li{font-size:11px}.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:9px;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:10px;font-weight:700;margin-bottom:6px}.required-mark{font-size:8px;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:12px/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:9px;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:10px}.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:10px}.content-editor-field{display:block;margin:17px 0}.editor .content-editor-field textarea,.editor .markdown-source{height:260px;background:#10162b;color:#e8ebff;font:11px/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:11px;font-weight:750}.advanced-editor p{font-size:10px;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:12px;line-height:1.55;color:var(--muted)}.metadata dd{overflow-wrap:anywhere}
|
|
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:11px;font-weight:750}.record-content-details>p{color:var(--muted);font-size:10px}.record-content-editor>span small{color:var(--muted);font-size:9px;font-weight:500}
|
|
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:10px}.setup-steps a.done>span:first-child{background:#dcefe4;color:#125733}.setup-steps strong,.setup-steps small{display:block}.setup-steps strong{font-size:10px}.setup-steps small{margin-top:3px;color:var(--muted);font-size:8px;line-height:1.4;font-weight:500}
|
|
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,.65fr) minmax(320px,1fr);gap:28px;align-items:end;margin-bottom:17px}.readiness-map-head h3{font-size:15px;margin:5px 0 0}.readiness-map-head>p{max-width:710px;color:var(--muted);font-size:11px;line-height:1.5;margin:0}.readiness-flow{display:grid;grid-template-columns:repeat(6,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:8px;font-weight:800}.readiness-flow strong{font-size:10px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:8px;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:7px;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:11px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:9px;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:9px;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:9px;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:8px}.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:9px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:14px;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:8px}.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:10px}.connections small{margin-top:3px;color:var(--muted);font-size:8px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:8px;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:10px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:8px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:11px}.external-source:hover strong{text-decoration:underline}
|
|
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:8px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:10px;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:8px;font-weight:700}
|
|
2219
|
+
.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:11px}.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:25px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:12px;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:11px;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:11px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:10px;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:11px}.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:10px;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:12px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:9px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:9px;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}
|
|
2220
|
+
.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:9px;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
|
+
@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
|
+
@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:20px}.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}}
|
|
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:8px;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
|
+
@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
|
+
@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(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:20px;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
|
+
@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
|
+
|
|
2230
|
+
body,button,input,select,textarea,dialog{color:var(--ink)}
|
|
2231
|
+
button,input,select,textarea{accent-color:var(--accent)}
|
|
2232
|
+
:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
|
|
2233
|
+
::selection{background:var(--accent-light);color:#000035}
|
|
2234
|
+
.skip-link{background:var(--panel);color:var(--ink);box-shadow:var(--shadow)}
|
|
2235
|
+
.sidebar{background:var(--sidebar);color:#eef1ff}
|
|
2236
|
+
.brand .mark{background:transparent}
|
|
2237
|
+
.brand small{color:#c5cae2}
|
|
2238
|
+
.nav-home,.nav-items a{color:#d5d9ed}
|
|
2239
|
+
.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:rgba(255,255,255,.11);color:#fff}
|
|
2240
|
+
.nav-heading{color:#b4bbdc}
|
|
2241
|
+
.nav-items small{color:#b8bed7}
|
|
2242
|
+
.side-foot{background:#000024;border-color:rgba(255,255,255,.14);color:#cbd0e5;z-index:1}
|
|
2243
|
+
.topbar{background:rgba(255,255,255,.9)}
|
|
2244
|
+
.search{background:var(--surface-soft);border-color:var(--line);color:var(--muted)}
|
|
2245
|
+
.search kbd{background:var(--panel);border-color:var(--line);color:var(--ink)}
|
|
2246
|
+
.hero{color:#f8f9ff;background:var(--primary-gradient)}
|
|
2247
|
+
.hero:after{border-color:rgba(138,161,255,.16)}
|
|
2248
|
+
.hero .kicker{color:#cbd3ff}
|
|
2249
|
+
.hero p:not(.kicker){color:#dde1f4}
|
|
2250
|
+
.hero-meta span{color:#e6e8f7;border-color:#6874ab}
|
|
2251
|
+
.metric,.panel,.record-table-wrap{background:var(--panel)}
|
|
2252
|
+
.metric>small,.progress-meta,.content-label,.record-table th,.record-table code,.record-table td>small,.metadata dt{color:var(--muted)}
|
|
2253
|
+
.progress,.resource-bars i{background:var(--surface-muted)}
|
|
2254
|
+
.progress span{background:linear-gradient(90deg,#0000a5,var(--accent-light))}
|
|
2255
|
+
.due-list a,.record-table td,.metadata>div,.history>div,.diagnostics>div,.result-list a{border-color:var(--line)}
|
|
2256
|
+
.resource-bars b{background:#6676dd}
|
|
2257
|
+
.catalog a,.record-table th,.empty{background:var(--surface-soft)}
|
|
2258
|
+
.catalog a:hover{background:var(--accent-soft)}
|
|
2259
|
+
.button{background:var(--panel);border-color:var(--line);color:var(--ink)}
|
|
2260
|
+
.button.primary{background:var(--primary-gradient);border-color:#000070;color:#fff}
|
|
2261
|
+
.button.primary:hover{filter:brightness(1.18)}
|
|
2262
|
+
.list-tools input,.list-tools select,.form-field input,.form-field select,.editor .form-field textarea{background:var(--field);border-color:var(--line);color:var(--ink)}
|
|
2263
|
+
input::placeholder,textarea::placeholder{color:var(--muted);opacity:1}
|
|
2264
|
+
.badge,.tag,.type-pill,.markdown code,.icon-button{background:var(--surface-muted)}
|
|
2265
|
+
.badge.good{background:#dcefe4;color:#125733}
|
|
2266
|
+
.badge.warn{background:#f6e8c9;color:#79500f}
|
|
2267
|
+
.badge.bad{background:#f7dfdc;color:#873027}
|
|
2268
|
+
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#dcefe4;color:#125733}
|
|
2269
|
+
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f7dfdc;color:#873027}
|
|
2270
|
+
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f6e8c9;color:#79500f}
|
|
2271
|
+
.markdown p,.markdown li{color:var(--ink)}
|
|
2272
|
+
.markdown pre,.editor textarea,.editor .content-editor-field textarea,.editor .markdown-source{background:var(--code-bg);color:var(--code-ink)}
|
|
2273
|
+
.markdown blockquote{border-color:var(--accent-light)}
|
|
2274
|
+
.empty{color:var(--muted)}
|
|
2275
|
+
.editor,.search-results,.alert-dialog{background:var(--panel);color:var(--ink);box-shadow:var(--shadow)}
|
|
2276
|
+
dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
2277
|
+
.muted,.form-field>small{color:var(--muted)}
|
|
2278
|
+
.setup-banner{background:var(--accent-soft);border-color:#ccd4ff}
|
|
2279
|
+
.form-field>.field-label,.content-editor-field>span{color:var(--ink)}
|
|
2280
|
+
.form-field input[readonly]{background:var(--field-readonly);color:var(--muted)}
|
|
2281
|
+
.checkbox-list{border-color:var(--line)}
|
|
2282
|
+
.checkbox-list label:hover{background:var(--surface-soft)}
|
|
2283
|
+
.missing-options{border-color:#d5ad55;background:#fff7dc;color:#6d4707}
|
|
2284
|
+
.nav-close{border-color:rgba(255,255,255,.28);background:rgba(255,255,255,.1);color:#fff}
|
|
2285
|
+
.nav-scrim{background:rgba(0,0,24,.52)}
|
|
2286
|
+
.record-table td[data-label]::before{color:var(--muted)}
|
|
2287
|
+
.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
|
+
.commit-dialog form{padding:23px}
|
|
2289
|
+
.commit-dialog form>p{color:var(--muted);font-size:11px;line-height:1.55}
|
|
2290
|
+
.commit-dialog label>span{display:block;font-size:10px;font-weight:720;margin-bottom:6px}
|
|
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:12px}
|
|
2292
|
+
.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:9px;overflow-wrap:anywhere}
|
|
2294
|
+
.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:10px}.onboarding-git-status small{color:var(--muted);font-size:9px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:9px}
|
|
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:10px}.obligation-preview small,.event-reminder-preview small{font-size:9px;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}
|
|
2298
|
+
.obligation-board{display:grid;grid-template-columns:repeat(3,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 22px 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:8px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:12px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:9px;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 24px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:11px;color:var(--muted);margin:0;max-width:720px}.event-trigger-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.event-trigger-card{display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;min-width:0}.event-trigger-card h3{font-size:13px;margin:6px 0}.event-trigger-card p:not(.kicker){font-size:10px;color:var(--muted);line-height:1.5;margin:0}.event-trigger-card ol{padding-left:20px;margin:15px 0;display:grid;gap:8px}.event-trigger-card li span,.event-trigger-card li small{display:block}.event-trigger-card li span{font-size:10px}.event-trigger-card li small{font-size:8px;color:var(--muted);margin-top:2px}.event-trigger-card>.button{margin-top:auto;align-self:flex-start}.event-run-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.event-run{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;min-width:0}.event-run-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.event-run-head h3{font-size:13px;margin:7px 0 3px}.event-run-head h3 a{text-decoration:none}.event-run-head small{font-size:9px;color:var(--muted)}.event-run-head>strong{font:500 22px Georgia,serif}.event-run>.progress{margin:13px 0}.event-actions{display:grid}.event-actions a{display:flex;gap:9px;text-decoration:none;padding:9px 0;border-top:1px solid var(--line);align-items:flex-start}.event-actions strong,.event-actions small{display:block}.event-actions strong{font-size:10px}.event-actions small{font-size:8px;color:var(--muted);margin-top:2px}
|
|
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:9px;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}
|
|
2300
|
+
.event-dialog label{display:block;margin-top:13px}.event-dialog label>span{display:block;font-size:10px;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:12px}.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:10px}.event-dialog-steps small{font-size:8px;color:var(--muted);margin-top:2px}
|
|
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:9px;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:11px}.packet-note,.packet-output>p{font-size:10px;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:10px;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:10px}.packet-list small{font-size:8px;color:var(--muted);margin-top:2px}
|
|
2302
|
+
.packet-preflight{display:grid;grid-template-columns:repeat(4,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:8px;text-transform:uppercase;letter-spacing:.07em}.packet-preflight strong{margin-top:3px;font-size:10px}
|
|
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:9px}.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:9px;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:10px}.preparation-stage summary small{margin-top:3px;color:var(--muted);font-size:8px;line-height:1.4}.preparation-stage summary b{flex:none;color:var(--muted);font-size:8px;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:9px}.preparation-items small{margin-top:3px;color:var(--muted);font-size:8px;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:9px;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}
|
|
2304
|
+
@media(max-width:900px){.obligation-board,.event-trigger-grid{grid-template-columns:1fr}.event-run-list{grid-template-columns:1fr}.packet-builder form,.packet-preflight{grid-template-columns:1fr 1fr}.packet-builder .button{align-self:end}}
|
|
2305
|
+
@media(max-width:760px){.preparation-stages{grid-template-columns:1fr}}
|
|
2306
|
+
@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}}
|
|
2308
|
+
|
|
2309
|
+
@media(prefers-color-scheme:dark){
|
|
2310
|
+
: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)}
|
|
2311
|
+
.topbar{background:rgba(0,0,0,.9)}
|
|
2312
|
+
.badge.good{background:#173b2b;color:#a8edc4}
|
|
2313
|
+
.badge.warn{background:#483714;color:#ffd991}
|
|
2314
|
+
.badge.bad{background:#4a252a;color:#ffb5ad}
|
|
2315
|
+
.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#173b2b;color:#a8edc4}
|
|
2316
|
+
.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#4a252a;color:#ffb5ad}
|
|
2317
|
+
.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991}
|
|
2318
|
+
.badge.status-overdue{background:#4a252a;color:#ffb5ad}
|
|
2319
|
+
.badge.status-due{background:#483714;color:#ffd991}
|
|
2320
|
+
.badge.status-complete{background:#173b2b;color:#a8edc4}
|
|
2321
|
+
.missing-options{border-color:#77612f;background:#382f19;color:#ffdc92}
|
|
2322
|
+
.status-dot.neutral{background:#9aabff}
|
|
2323
|
+
}
|
|
2324
|
+
`;
|
|
2325
|
+
|
|
2326
|
+
function safeJson(value) {
|
|
2327
|
+
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
2328
|
+
}
|