filegrc 0.13.2 → 0.13.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/web.js +160 -70
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/web.js CHANGED
@@ -43,7 +43,7 @@ export function renderIndex(state = null) {
43
43
  </head>
44
44
  <body>
45
45
  <a class="skip-link" href="#main">Skip to content</a>
46
- <div id="app"><div class="loading">Loading workspace…</div></div>
46
+ <div id="app"><div class="loading" role="status" aria-live="polite" aria-busy="true"><span class="spinner" aria-hidden="true"></span><span>Loading workspace…</span></div></div>
47
47
  ${snapshot}
48
48
  <script src="./filegrc-app.js" defer></script>
49
49
  </body>
@@ -98,6 +98,44 @@ let mutationStateRefreshRequested = false;
98
98
  let programSelectionGeneration = 0;
99
99
  let expiredStateRefresh = null;
100
100
 
101
+ function loadingIndicator(label) {
102
+ return '<span class="spinner" aria-hidden="true"></span><span>' + esc(label) + '</span>';
103
+ }
104
+
105
+ function setLoadingContent(element, label) {
106
+ if (!element) return;
107
+ element.classList.add("is-loading");
108
+ element.setAttribute("aria-busy", "true");
109
+ element.innerHTML = loadingIndicator(label);
110
+ }
111
+
112
+ function clearLoadingContent(element, label = "") {
113
+ if (!element) return;
114
+ element.classList.remove("is-loading");
115
+ element.removeAttribute("aria-busy");
116
+ element.textContent = label;
117
+ }
118
+
119
+ function setButtonBusy(button, busy, label = "") {
120
+ if (!button) return;
121
+ if (busy) {
122
+ if (!button.dataset.loadingIdle) {
123
+ button.dataset.loadingIdle = button.innerHTML;
124
+ button.dataset.loadingWasDisabled = String(button.disabled);
125
+ }
126
+ button.disabled = true;
127
+ setLoadingContent(button, label);
128
+ return;
129
+ }
130
+ const idle = button.dataset.loadingIdle;
131
+ const wasDisabled = button.dataset.loadingWasDisabled === "true";
132
+ clearLoadingContent(button);
133
+ if (idle !== undefined) button.innerHTML = idle;
134
+ button.disabled = wasDisabled;
135
+ delete button.dataset.loadingIdle;
136
+ delete button.dataset.loadingWasDisabled;
137
+ }
138
+
101
139
  start().catch((error) => {
102
140
  root.innerHTML = '<main class="fatal"><h1>Could Not Load the Workspace</h1><pre></pre></main>';
103
141
  root.querySelector("pre").textContent = error.stack || error.message;
@@ -188,7 +226,7 @@ function renderStateLoading(main, route, sections) {
188
226
  const message = failed
189
227
  ? state.sectionErrors?.[failed] || "Reload the workspace and try again."
190
228
  : "Records are ready. FileGRC is calculating the information needed for this page.";
191
- main.innerHTML = '<div class="page"><section class="panel state-loading" role="status" aria-live="polite"><p class="kicker">' + (failed ? "Loading error" : "One moment") + '</p><h2>' + esc(title) + '</h2><p>' + esc(message) + '</p></section></div>';
229
+ main.innerHTML = '<div class="page"><section class="panel state-loading" role="' + (failed ? "alert" : "status") + '" aria-live="polite"' + (failed ? "" : ' aria-busy="true"') + '>' + (failed ? "" : '<span class="spinner spinner-large" aria-hidden="true"></span>') + '<div><p class="kicker">' + (failed ? "Loading error" : "One moment") + '</p><h2>' + esc(title) + '</h2><p>' + esc(message) + '</p></div></section></div>';
192
230
  }
193
231
 
194
232
  function loadStateForRoute() {
@@ -373,13 +411,17 @@ function topbar(route) {
373
411
  const programSelect = programs.length > 1 && !state.readOnly
374
412
  ? '<label class="program-select"><span class="sr-only">Current Program</span><select data-program-select>' + programs.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === state.selectedProgramId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label>'
375
413
  : "";
376
- 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><div class="topbar-status">' + programSelect + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip" href="#/repository"><span class="status-dot ' + validationTone + '"></span>' + validationLabel + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
414
+ const validationMark = validationLoading && !repositoryError ? '<span class="spinner" aria-hidden="true"></span>' : '<span class="status-dot ' + validationTone + '"></span>';
415
+ const repositoryBusy = repositoryLoading && !repositoryError || state.repository?.status === "syncing";
416
+ const repositoryMark = repositoryBusy ? '<span class="spinner" aria-hidden="true"></span>' : '<span class="status-dot ' + repositoryTone + '"></span>';
417
+ 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><div class="topbar-status">' + programSelect + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip' + (validationLoading && !repositoryError ? ' is-loading' : '') + '" href="#/repository"' + (validationLoading && !repositoryError ? ' aria-busy="true"' : '') + '>' + validationMark + validationLabel + '</a><a class="repo-chip' + (repositoryBusy ? ' is-loading' : '') + '" href="#/repository"' + (repositoryBusy ? ' aria-busy="true"' : '') + '>' + repositoryMark + esc(repositoryLabel) + '</a></div>';
377
418
  }
378
419
 
379
420
  function topbarProgramReadiness() {
380
421
  if (state.sections?.program !== "complete") {
381
- const label = state.sections?.program === "loading" ? "…" : "View";
382
- return '<a class="topbar-readiness" href="#/"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
422
+ const loading = state.sections?.program !== "error";
423
+ const label = loading ? '<span class="spinner" aria-hidden="true"></span><span class="sr-only">Loading</span>' : "View";
424
+ return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
383
425
  }
384
426
  const progress = dashboardProgramReadiness(state.programReadiness);
385
427
  const detail = progress.complete + " of " + progress.total + " readiness items complete";
@@ -394,7 +436,7 @@ function repositoryStatusTone(status) {
394
436
 
395
437
  function repositorySyncAlert() {
396
438
  if (state.repository?.status === "syncing") {
397
- return '<div class="repository-sync-alert syncing" role="status" aria-live="polite"><span class="status-dot neutral"></span><span><strong>Saved and committed locally.</strong> Git push is continuing in the background. Other changes unlock when synchronization finishes.</span><a href="#/repository">View status</a></div>';
439
+ return '<div class="repository-sync-alert syncing is-loading" role="status" aria-live="polite" aria-busy="true"><span class="spinner" aria-hidden="true"></span><span><strong>Saved and committed locally.</strong> Git push is continuing in the background. Other changes unlock when synchronization finishes.</span><a href="#/repository">View status</a></div>';
398
440
  }
399
441
  const message = state.repository?.backgroundSyncError;
400
442
  if (!message) return "";
@@ -992,8 +1034,10 @@ function openCollectionReviewDialog(type) {
992
1034
  expectedRevision: assessment.reviewRevision || undefined
993
1035
  };
994
1036
  form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = true; });
1037
+ const submit = form.querySelector('button[type="submit"]');
1038
+ setButtonBusy(submit, true, "Saving…");
995
1039
  try {
996
- saveStatus.textContent = "Validating and saving…";
1040
+ setLoadingContent(saveStatus, "Validating and saving…");
997
1041
  const prefetch = await repositoryPrefetch;
998
1042
  if (prefetch?.error) throw prefetch.error;
999
1043
  const response = await localFetch("/api/collection-review", {
@@ -1002,14 +1046,15 @@ function openCollectionReviewDialog(type) {
1002
1046
  body: JSON.stringify({ ...payload, confirmed: true, prefetchToken: prefetch?.token })
1003
1047
  });
1004
1048
  if (!response.ok) throw new Error(await responseMessage(response));
1005
- saveStatus.textContent = "Saved locally. Refreshing page…";
1049
+ setLoadingContent(saveStatus, "Saved locally. Refreshing page…");
1006
1050
  applyMutationState(await response.json());
1007
1051
  dialog.close();
1008
1052
  render();
1009
1053
  } catch (requestError) {
1010
- saveStatus.textContent = "Not saved";
1054
+ clearLoadingContent(saveStatus, "Not saved");
1011
1055
  error.textContent = requestError.message;
1012
1056
  } finally {
1057
+ setButtonBusy(submit, false);
1013
1058
  form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = false; });
1014
1059
  }
1015
1060
  });
@@ -1920,8 +1965,7 @@ async function openObligationRuleActivation(ruleId) {
1920
1965
  const approvedByIds = [...form.querySelectorAll('input[name="approver"]:checked')].map(({ value }) => value);
1921
1966
  if (!approvedByIds.length) return void (error.textContent = "Select at least one approver.");
1922
1967
  const button = form.querySelector('button[type="submit"]');
1923
- button.disabled = true;
1924
- button.textContent = "Activating…";
1968
+ setButtonBusy(button, true, "Activating…");
1925
1969
  error.textContent = "";
1926
1970
  try {
1927
1971
  const saved = await localFetch("/api/obligation-rule-activations", {
@@ -1942,8 +1986,7 @@ async function openObligationRuleActivation(ruleId) {
1942
1986
  render();
1943
1987
  } catch (caught) {
1944
1988
  error.textContent = caught.message;
1945
- button.disabled = false;
1946
- button.textContent = "Activate schedule";
1989
+ setButtonBusy(button, false);
1947
1990
  }
1948
1991
  });
1949
1992
  } catch (error) {
@@ -2079,6 +2122,8 @@ function openObligationEventDialog(trigger) {
2079
2122
  const form = event.currentTarget;
2080
2123
  if (!form.reportValidity()) return;
2081
2124
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
2125
+ const submit = form.querySelector('button[type="submit"]');
2126
+ setButtonBusy(submit, true, "Adding tasks…");
2082
2127
  try {
2083
2128
  const response = await localFetch("/api/obligation-events", {
2084
2129
  method: "POST",
@@ -2103,6 +2148,7 @@ function openObligationEventDialog(trigger) {
2103
2148
  history.replaceState(null, "", "#/stage/run");
2104
2149
  render();
2105
2150
  } catch (error) {
2151
+ setButtonBusy(submit, false);
2106
2152
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
2107
2153
  form.querySelector(".dialog-error").textContent = error.message;
2108
2154
  }
@@ -2135,6 +2181,8 @@ function openExternalReviewerGovernanceDialog() {
2135
2181
  const error = dialog.querySelector(".dialog-error");
2136
2182
  error.textContent = "";
2137
2183
  form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = true; });
2184
+ const submit = form.querySelector('[data-preview-governance]');
2185
+ setButtonBusy(submit, true, previewedPayload ? "Applying…" : "Preparing preview…");
2138
2186
  try {
2139
2187
  if (!previewedPayload) {
2140
2188
  const response = await localFetch("/api/external-reviewer-governance/preview", {
@@ -2161,6 +2209,8 @@ function openExternalReviewerGovernanceDialog() {
2161
2209
  } catch (requestError) {
2162
2210
  error.textContent = requestError.message;
2163
2211
  } finally {
2212
+ setButtonBusy(submit, false);
2213
+ submit.textContent = previewedPayload ? "Confirm and apply" : "Preview bundle";
2164
2214
  form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = false; });
2165
2215
  }
2166
2216
  });
@@ -2197,6 +2247,8 @@ function openNextAuditCycleDialog(prior) {
2197
2247
  const error = dialog.querySelector(".dialog-error");
2198
2248
  error.textContent = "";
2199
2249
  form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
2250
+ const submit = form.querySelector("[data-preview-cycle]");
2251
+ setButtonBusy(submit, true, previewedPayload ? "Creating…" : "Preparing preview…");
2200
2252
  try {
2201
2253
  if (!previewedPayload) {
2202
2254
  const response = await localFetch("/api/audit-cycle/preview", {
@@ -2225,6 +2277,8 @@ function openNextAuditCycleDialog(prior) {
2225
2277
  } catch (requestError) {
2226
2278
  error.textContent = requestError.message;
2227
2279
  } finally {
2280
+ setButtonBusy(submit, false);
2281
+ submit.textContent = previewedPayload ? "Confirm and create" : "Preview carry-forward";
2228
2282
  form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
2229
2283
  }
2230
2284
  });
@@ -2314,6 +2368,8 @@ function openApplicabilityReviewDialog(type, entries) {
2314
2368
  ])
2315
2369
  };
2316
2370
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = true; });
2371
+ const submit = form.querySelector("[data-preview-review]");
2372
+ setButtonBusy(submit, true, previewedPayload ? "Saving…" : "Preparing preview…");
2317
2373
  try {
2318
2374
  if (!previewedPayload) {
2319
2375
  const response = await localFetch("/api/applicability-review/preview", {
@@ -2327,7 +2383,7 @@ function openApplicabilityReviewDialog(type, entries) {
2327
2383
  dialog.querySelector(".workflow-preview").innerHTML = '<strong>Review before saving</strong><p>' + preview.reviewedIds.length + ' ' + pluralize("decision", preview.reviewedIds.length) + ' will be saved with the reviewer, review date, and current scope recorded automatically.</p>';
2328
2384
  dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
2329
2385
  } else {
2330
- saveStatus.textContent = "Validating and saving…";
2386
+ setLoadingContent(saveStatus, "Validating and saving…");
2331
2387
  const prefetch = await repositoryPrefetch;
2332
2388
  if (prefetch?.error) throw prefetch.error;
2333
2389
  const response = await localFetch("/api/applicability-review", {
@@ -2336,14 +2392,17 @@ function openApplicabilityReviewDialog(type, entries) {
2336
2392
  body: JSON.stringify({ ...previewedPayload, confirmed: true, prefetchToken: prefetch?.token })
2337
2393
  });
2338
2394
  if (!response.ok) throw new Error(await responseMessage(response));
2339
- saveStatus.textContent = "Saved locally. Refreshing page…";
2395
+ setLoadingContent(saveStatus, "Saved locally. Refreshing page…");
2340
2396
  applyMutationState(await response.json());
2341
2397
  dialog.close();
2342
2398
  render();
2343
2399
  }
2344
2400
  } catch (requestError) {
2401
+ clearLoadingContent(saveStatus, "Not saved");
2345
2402
  error.textContent = requestError.message;
2346
2403
  } finally {
2404
+ setButtonBusy(submit, false);
2405
+ submit.textContent = previewedPayload ? "Confirm and save" : "Preview decisions";
2347
2406
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
2348
2407
  }
2349
2408
  });
@@ -2388,8 +2447,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2388
2447
  main.querySelector("#initialize-audit-work")?.addEventListener("click", async (event) => {
2389
2448
  const button = event.currentTarget;
2390
2449
  const error = main.querySelector(".audit-preparation-error");
2391
- button.disabled = true;
2392
- button.textContent = "Initializing…";
2450
+ setButtonBusy(button, true, "Initializing…");
2393
2451
  error.textContent = "";
2394
2452
  try {
2395
2453
  const response = await localFetch("/api/audit-preparation", {
@@ -2402,8 +2460,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2402
2460
  render();
2403
2461
  } catch (caught) {
2404
2462
  error.textContent = caught.message;
2405
- button.disabled = false;
2406
- button.textContent = "Initialize audit work";
2463
+ setButtonBusy(button, false);
2407
2464
  }
2408
2465
  });
2409
2466
  main.querySelector("#packet-form").addEventListener("submit", async (event) => {
@@ -2412,8 +2469,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2412
2469
  if (!form.reportValidity()) return;
2413
2470
  const button = form.querySelector('button[type="submit"]');
2414
2471
  const error = main.querySelector(".dialog-error");
2415
- button.disabled = true;
2416
- button.textContent = "Generating…";
2472
+ setButtonBusy(button, true, "Generating…");
2417
2473
  error.textContent = "";
2418
2474
  try {
2419
2475
  const response = await localFetch("/api/evidence-packet", {
@@ -2434,8 +2490,8 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2434
2490
  } catch (caught) {
2435
2491
  error.textContent = caught.message;
2436
2492
  } finally {
2493
+ setButtonBusy(button, false);
2437
2494
  button.disabled = state.readOnly;
2438
- button.textContent = draft ? "Generate draft" : "Generate packet";
2439
2495
  }
2440
2496
  });
2441
2497
  if (latestPacketResult && latestPacketState === state && latestPacketResult.packet.audit?.id === selected?.id) {
@@ -2707,7 +2763,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2707
2763
  const definition = state.model.resources[type];
2708
2764
  if (!entry || !definition) return renderNotFound(main);
2709
2765
  if (entry.detailsLoaded === false) {
2710
- main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record…</section></div>';
2766
+ main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading is-loading" role="status" aria-live="polite" aria-busy="true">' + loadingIndicator("Loading record…") + '</section></div>';
2711
2767
  loadResourceDetail(type, id);
2712
2768
  return;
2713
2769
  }
@@ -2779,8 +2835,10 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2779
2835
  ? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><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></section>'
2780
2836
  : "";
2781
2837
  const participationPanel = entry.historyLoaded === false
2782
- ? '<section class="panel detail-support-panel" role="status"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Loading participation and file history…</p></section>'
2783
- : personParticipation(entry);
2838
+ ? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
2839
+ : entry.historyError
2840
+ ? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
2841
+ : personParticipation(entry);
2784
2842
  const supportPanels = renderDetailSupport({
2785
2843
  hasRecordBody,
2786
2844
  workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
@@ -3007,8 +3065,8 @@ async function loadResourceHistory(type, id) {
3007
3065
  const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
3008
3066
  if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
3009
3067
  const request = (async () => {
3068
+ const token = state.stateToken;
3010
3069
  try {
3011
- const token = state.stateToken;
3012
3070
  const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
3013
3071
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
3014
3072
  if (response.status === 409 && token) {
@@ -3029,8 +3087,15 @@ async function loadResourceHistory(type, id) {
3029
3087
  entry.historyLoaded = true;
3030
3088
  const route = parseRoute();
3031
3089
  if (route.name === "detail" && route.type === type && route.id === id) render();
3032
- } catch {
3033
- // History and participation are supplemental. The record stays usable.
3090
+ } catch (error) {
3091
+ if (token && state.stateToken !== token) return;
3092
+ const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
3093
+ if (entry) {
3094
+ entry.historyLoaded = true;
3095
+ entry.historyError = error.message;
3096
+ }
3097
+ const route = parseRoute();
3098
+ if (route.name === "detail" && route.type === type && route.id === id) render();
3034
3099
  } finally {
3035
3100
  for (const [requestKey, pending] of resourceDetailRequests) {
3036
3101
  if (pending === request) resourceDetailRequests.delete(requestKey);
@@ -3423,8 +3488,8 @@ async function runRepositoryGitAction(action) {
3423
3488
  status.textContent = "";
3424
3489
  status.classList.remove("error");
3425
3490
  }
3491
+ if (active) setButtonBusy(active, true, label);
3426
3492
  buttons.forEach((button) => { button.disabled = true; });
3427
- if (active) active.textContent = label;
3428
3493
  try {
3429
3494
  const response = await localFetch("/api/git/" + action, { method: "POST" });
3430
3495
  if (!response.ok) throw new Error(await responseMessage(response));
@@ -3440,8 +3505,8 @@ async function runRepositoryGitAction(action) {
3440
3505
  ? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
3441
3506
  : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
3442
3507
  } catch (cause) {
3508
+ if (active) setButtonBusy(active, false);
3443
3509
  buttons.forEach((button, index) => { button.disabled = disabled[index]; });
3444
- if (active) active.textContent = action === "pull" ? "Check incoming commits" : action === "retry-sync" ? "Retry sync" : "Push";
3445
3510
  if (status) {
3446
3511
  status.textContent = cause.message;
3447
3512
  status.classList.add("error");
@@ -3467,8 +3532,10 @@ function openCommitDialog() {
3467
3532
  const form = event.currentTarget;
3468
3533
  if (!form.reportValidity()) return;
3469
3534
  const button = form.querySelector('button[type="submit"]');
3470
- form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
3471
- button.textContent = busyLabel;
3535
+ const controls = [...form.querySelectorAll("button,input")];
3536
+ const disabled = controls.map((control) => control.disabled);
3537
+ setButtonBusy(button, true, busyLabel);
3538
+ controls.forEach((control) => { control.disabled = true; });
3472
3539
  try {
3473
3540
  const response = await localFetch("/api/commit", {
3474
3541
  method: "POST",
@@ -3490,8 +3557,8 @@ function openCommitDialog() {
3490
3557
  status.classList.toggle("error", !result.pushed && !result.pushSkipped);
3491
3558
  }
3492
3559
  } catch (error) {
3493
- form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
3494
- button.textContent = actionLabel;
3560
+ setButtonBusy(button, false);
3561
+ controls.forEach((control, index) => { control.disabled = disabled[index]; });
3495
3562
  form.querySelector(".dialog-error").textContent = error.message;
3496
3563
  }
3497
3564
  });
@@ -4072,9 +4139,11 @@ function setOnboardingBusy(busy, label = "") {
4072
4139
  onboardingBusy = busy;
4073
4140
  onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
4074
4141
  const next = onboardingDialog.querySelector('[data-onboarding="next"]');
4075
- if (next && label) next.textContent = label;
4142
+ const draft = onboardingDialog.querySelector('[data-onboarding="draft"]');
4076
4143
  const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
4077
- if (skip && label && onboardingStep !== onboardingSteps().length - 1) skip.textContent = label;
4144
+ const retry = onboardingDialog.querySelector(".onboarding-retry-sync");
4145
+ const active = label === "Saving draft…" ? draft : label === "Skipping…" ? skip : label === "Retrying sync…" ? retry : next;
4146
+ if (busy && active && label) setLoadingContent(active, label);
4078
4147
  if (busy && onboardingStep === onboardingSteps().length - 1) {
4079
4148
  onboardingStillWorkingTimer = setTimeout(() => {
4080
4149
  const status = onboardingDialog?.querySelector(".onboarding-save-status");
@@ -4754,12 +4823,8 @@ function wireStructuredObjectEditors(dialog) {
4754
4823
  dialog.querySelector("form").addEventListener("click", (event) => {
4755
4824
  const bind = event.target.closest("[data-bind-review-revisions]");
4756
4825
  if (bind) {
4757
- const previousLabel = bind.textContent;
4758
4826
  bindCurrentReviewRevisions(dialog, bind).catch((error) => {
4759
4827
  dialog.querySelector(".dialog-error").textContent = error.message;
4760
- }).finally(() => {
4761
- bind.disabled = false;
4762
- if (bind.textContent === "Binding…") bind.textContent = previousLabel;
4763
4828
  });
4764
4829
  return;
4765
4830
  }
@@ -4835,23 +4900,28 @@ async function bindCurrentReviewRevisions(dialog, button) {
4835
4900
  throw new Error("Select the retention schedule, Information Types, and operational scope before binding their current revisions.");
4836
4901
  }
4837
4902
  const ids = [...new Set(Object.values(selected).flat().concat(scheduleDocumentId, retentionUseReviewIds(dialog)).filter(Boolean))];
4838
- button.disabled = true;
4839
- button.textContent = "Binding…";
4840
- const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
4841
- const response = await localFetch("/api/review-revisions?" + query);
4842
- if (!response.ok) throw new Error(await responseMessage(response));
4843
- const { revisions } = await response.json();
4844
- const editor = button.closest(".string-map-editor");
4845
- const items = editor.querySelector(".string-map-items");
4846
- items.replaceChildren();
4847
- for (const [key, value] of Object.entries(revisions)) {
4848
- items.append(editor.querySelector("template").content.cloneNode(true));
4849
- const row = items.lastElementChild;
4850
- row.querySelector("[data-map-key]").value = key;
4851
- row.querySelector("[data-map-value]").value = value;
4903
+ setButtonBusy(button, true, "Binding…");
4904
+ let bound = false;
4905
+ try {
4906
+ const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
4907
+ const response = await localFetch("/api/review-revisions?" + query);
4908
+ if (!response.ok) throw new Error(await responseMessage(response));
4909
+ const { revisions } = await response.json();
4910
+ const editor = button.closest(".string-map-editor");
4911
+ const items = editor.querySelector(".string-map-items");
4912
+ items.replaceChildren();
4913
+ for (const [key, value] of Object.entries(revisions)) {
4914
+ items.append(editor.querySelector("template").content.cloneNode(true));
4915
+ const row = items.lastElementChild;
4916
+ row.querySelector("[data-map-key]").value = key;
4917
+ row.querySelector("[data-map-value]").value = value;
4918
+ }
4919
+ bound = true;
4920
+ dialog.querySelector(".dialog-error").textContent = "";
4921
+ } finally {
4922
+ setButtonBusy(button, false);
4923
+ if (bound) button.textContent = "Refresh current revisions";
4852
4924
  }
4853
- button.textContent = "Refresh current revisions";
4854
- dialog.querySelector(".dialog-error").textContent = "";
4855
4925
  }
4856
4926
 
4857
4927
  function retentionUseReviewIds(dialog) {
@@ -5698,16 +5768,16 @@ function prefetchRepositoryForReview(status = null) {
5698
5768
  if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
5699
5769
  return Promise.resolve(null);
5700
5770
  }
5701
- if (status) status.textContent = "Checking repository…";
5771
+ if (status) setLoadingContent(status, "Checking repository…");
5702
5772
  return fetch("/api/git/prefetch", { method: "POST" })
5703
5773
  .then(async (response) => {
5704
5774
  if (!response.ok) throw new Error(await responseMessage(response));
5705
5775
  const result = await response.json();
5706
- if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
5776
+ if (status) clearLoadingContent(status, result.status === "checked" ? "Repository checked" : "Ready to save");
5707
5777
  return result;
5708
5778
  })
5709
5779
  .catch((error) => {
5710
- if (status) status.textContent = "Repository check failed";
5780
+ if (status) clearLoadingContent(status, "Repository check failed");
5711
5781
  return null;
5712
5782
  });
5713
5783
  }
@@ -5756,7 +5826,10 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
5756
5826
  clearTimeout(dialog._stillWorkingTimer);
5757
5827
  dialog._stillWorkingTimer = null;
5758
5828
  dialog.dataset.mutationBusy = busy ? "true" : "false";
5829
+ const button = dialog.querySelector("#save-record,#save-content,#activate-policy") || dialog.querySelector('button[type="submit"]');
5830
+ if (busy && button) setButtonBusy(button, true, label);
5759
5831
  dialog.querySelectorAll("button,input,select,textarea").forEach((control) => {
5832
+ if (control === button) return;
5760
5833
  if (busy) {
5761
5834
  control.dataset.mutationWasDisabled = control.disabled ? "true" : "false";
5762
5835
  control.disabled = true;
@@ -5765,13 +5838,15 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
5765
5838
  delete control.dataset.mutationWasDisabled;
5766
5839
  }
5767
5840
  });
5768
- const button = dialog.querySelector("#save-record,#save-content,#activate-policy");
5769
- if (button) button.textContent = busy ? label : idleLabel;
5841
+ if (!busy && button) {
5842
+ setButtonBusy(button, false);
5843
+ if (idleLabel) button.textContent = idleLabel;
5844
+ }
5770
5845
  const status = dialog.querySelector(".save-status");
5771
- if (status) status.textContent = "";
5846
+ if (status) clearLoadingContent(status);
5772
5847
  if (busy) {
5773
5848
  dialog._stillWorkingTimer = setTimeout(() => {
5774
- if (status) status.textContent = "Still working. Recalculating readiness and repository state.";
5849
+ if (status) setLoadingContent(status, "Still working. Recalculating readiness and repository state.");
5775
5850
  }, 1_500);
5776
5851
  }
5777
5852
  }
@@ -5815,6 +5890,8 @@ async function responseMessage(response) {
5815
5890
  try { return JSON.parse(source).error || source; } catch { return source; }
5816
5891
  }
5817
5892
  async function localFetch(url, options) {
5893
+ const requestProgramId = state?.selectedProgramId || null;
5894
+ const requestProgramGeneration = programSelectionGeneration;
5818
5895
  const requestUrl = new URL(url, location.origin);
5819
5896
  if (state?.selectedProgramId && !requestUrl.searchParams.has("programId")) {
5820
5897
  requestUrl.searchParams.set("programId", state.selectedProgramId);
@@ -5837,17 +5914,24 @@ async function localFetch(url, options) {
5837
5914
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
5838
5915
  const previousChip = chip?.innerHTML;
5839
5916
  let repositoryRefreshed = false;
5840
- if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
5917
+ if (chip) {
5918
+ chip.classList.add("is-loading");
5919
+ chip.setAttribute("aria-busy", "true");
5920
+ chip.innerHTML = loadingIndicator("Syncing");
5921
+ }
5841
5922
  try {
5842
5923
  const response = await fetch(scopedUrl, requestOptions);
5843
5924
  if (synchronizing && !response.ok) {
5844
5925
  try {
5845
- const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
5926
+ const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(requestProgramId || ""));
5846
5927
  if (stateResponse.ok) {
5847
- state = await stateResponse.json();
5928
+ const refreshedState = normalizeAppState(await stateResponse.json());
5929
+ if (programSelectionGeneration !== requestProgramGeneration || (state?.selectedProgramId || null) !== requestProgramId) return response;
5930
+ state = refreshedState;
5931
+ repositoryRefreshed = true;
5932
+ scheduleRepositorySyncPoll();
5848
5933
  if (chip?.isConnected) {
5849
5934
  chip.innerHTML = '<span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span>' + esc(state.repository.label);
5850
- repositoryRefreshed = true;
5851
5935
  }
5852
5936
  }
5853
5937
  } catch {
@@ -5858,7 +5942,11 @@ async function localFetch(url, options) {
5858
5942
  } catch {
5859
5943
  throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
5860
5944
  } finally {
5861
- if (!repositoryRefreshed && chip?.isConnected && previousChip) chip.innerHTML = previousChip;
5945
+ if (chip?.isConnected) {
5946
+ chip.classList.remove("is-loading");
5947
+ chip.removeAttribute("aria-busy");
5948
+ if (!repositoryRefreshed && previousChip) chip.innerHTML = previousChip;
5949
+ }
5862
5950
  }
5863
5951
  }
5864
5952
  async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
@@ -5867,9 +5955,11 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
5867
5955
 
5868
5956
  export const APP_STYLES = String.raw`
5869
5957
  :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}
5870
- *{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:18px}.brand small{font-size:13.2px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:15.6px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:12px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:12px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:13.2px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:20.4px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:10.8px;margin:0}.search{height:39px;max-width:240px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:15.6px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:12px}.mobile-sidebar-search{display:none}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:33.6px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:15.6px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:12px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:16.8px;margin:4px 0 0}.panel-head>a{font-size:13.2px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:36px;font-weight:500;display:block}.progress-number span{font-size:12px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:10.8px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:12px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:13.2px}.due-list small{font-size:10.8px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:12px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:12px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:15.6px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:14.4px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:14.4px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:12px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:13.2px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:10.8px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:10.8px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:10.8px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:13.2px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:10.8px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:34.8px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:27.6px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:18px;margin-top:1.7em}.markdown p,.markdown li{font-size:15.6px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:13.2px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:13.2px;min-width:0}.compact-json{white-space:pre-wrap;font-size:10.8px}.git-panel>code{font-size:10.8px;word-break:break-all}.git-panel p{font-size:12px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:10.8px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:12px}.history small{font-size:10.8px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:13.2px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:13.2px;overflow-wrap:anywhere}.diagnostics code{font-size:10.8px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:26.4px;cursor:pointer}.editor form>p{font-size:13.2px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:13.2px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
5958
+ *{box-sizing:border-box}body{margin:0;min-width:320px;background:var(--paper)}button,input,select,textarea{font:inherit}a{color:inherit}@keyframes spinner-rotate{to{transform:rotate(360deg)}}.spinner{display:inline-block;flex:0 0 auto;width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spinner-rotate .7s linear infinite}.spinner-large{width:30px;height:30px;border-width:3px}.loading,.detail-loading,.muted.is-loading,.save-status.is-loading,.onboarding-save-status.is-loading,.button.is-loading{display:flex;align-items:center;gap:9px}.loading{min-height:100vh;justify-content:center;color:var(--muted)}.state-loading{display:flex;align-items:flex-start;gap:16px}.state-loading>.spinner{margin-top:5px;color:var(--accent)}.state-loading h2{margin:5px 0 8px}.state-loading p:last-child{margin:0;color:var(--muted)}.skip-link{position:fixed;left:1rem;top:-4rem;z-index:100;padding:.7rem 1rem;background:#fff}.skip-link:focus{top:1rem}.loading,.fatal{padding:3rem}.shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:248px;background:var(--sidebar);color:#eef1ff;padding:25px 18px 18px;overflow:auto;z-index:20}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;margin:0 7px 27px}.brand .mark{display:block;width:39px;height:39px;border-radius:10px}.brand strong,.brand small{display:block}.brand strong{color:#fff;font-size:18px}.brand small{font-size:13.2px;color:#c5cae2;margin-top:2px}.nav-home,.nav-items a{display:flex;justify-content:space-between;align-items:center;text-decoration:none;border-radius:7px;padding:8px 10px;font-size:15.6px;color:#d5d9ed}.nav-home{margin-bottom:9px}.nav-home:hover,.nav-items a:hover,.nav-home.current,.nav-items a.current{background:#202066;color:#fff}.nav-heading{width:100%;border:0;background:none;color:#b4bbdc;text-transform:uppercase;letter-spacing:.11em;font-size:12px;font-weight:750;display:flex;align-items:center;justify-content:space-between;padding:13px 10px 5px;cursor:pointer}.chevron{display:grid;place-items:center;width:14px;height:22px;font-size:0;line-height:1;transform:none}.chevron:before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transform-origin:center;transition:transform .15s}.nav-items{display:none}.nav-group.open .nav-items{display:block}.nav-items small{font-size:12px;color:#b8bed7}.side-foot{position:sticky;bottom:-18px;margin:25px -18px -18px;padding:17px 25px;background:#000024;border-top:1px solid #34345f;color:#cbd0e5;font-size:13.2px;display:flex;align-items:center;gap:8px}.status-dot{width:8px;height:8px;border-radius:50%;background:#9aa39f;display:inline-block;flex:0 0 auto}.status-dot.good,.badge.good{background:#6abf8c}.status-dot.warn,.badge.warn{background:#e9a445}.status-dot.bad,.badge.bad{background:#dc6c5d}.status-dot.neutral{background:#9aabff}.workspace{grid-column:2;min-width:0}.topbar{height:86px;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line);padding:0 32px;display:flex;align-items:center;gap:23px;position:sticky;top:0;z-index:10}.topbar>div:first-of-type{min-width:190px}.topbar h1{font-size:20.4px;line-height:1.1;margin:3px 0 0}.eyebrow,.kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.12em;font-weight:760;font-size:10.8px;margin:0}.search{height:39px;max-width:240px;flex:1;margin-left:auto;display:flex;align-items:center;gap:9px;background:#f2f4fa;border:1px solid #dfe3ef;border-radius:8px;padding:0 10px;color:#5d6475}.search input{border:0;outline:0;background:none;min-width:0;flex:1;font-size:15.6px}.search kbd{background:#fff;border:1px solid #dfe3ef;border-radius:4px;padding:1px 5px;font-size:12px}.mobile-sidebar-search{display:none}.repo-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.mobile-nav{display:none}.page{padding:30px 34px 70px;max-width:1510px;margin:auto}.hero{color:#f8f9ff;background:linear-gradient(120deg,#000070,#000035);border-radius:13px;padding:28px 31px;display:flex;justify-content:space-between;align-items:end;min-height:158px;box-shadow:var(--shadow);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:270px;height:270px;border:55px solid rgba(138,161,255,.1);border-radius:50%;right:-80px;top:-145px}.hero .kicker{color:#cbd3ff}.hero h2{font-family:Georgia,serif;font-weight:500;font-size:33.6px;margin:10px 0 8px;letter-spacing:-.02em}.hero p:not(.kicker){margin:0;color:#dde1f4;font-size:15.6px;max-width:650px}.hero-meta{display:flex;gap:15px;position:relative;z-index:1}.hero-meta span{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#e6e8f7;border-left:1px solid #6874ab;padding-left:15px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.metric{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.metric-label{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:7px}.metric>strong{display:block;font-family:Georgia,serif;font-size:30px;font-weight:500;margin:8px 0 2px}.metric>small{font-size:12px;color:#697184}.dashboard-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.panel{background:#fff;border:1px solid var(--line);border-radius:11px;padding:21px;min-width:0;box-shadow:0 2px 8px rgba(21,40,33,.025)}.span-2{grid-column:span 2}.panel-head{display:flex;align-items:start;justify-content:space-between;gap:15px;margin-bottom:18px}.panel-head h3{font-size:16.8px;margin:4px 0 0}.panel-head>a{font-size:13.2px;color:var(--accent);font-weight:700}.audit-progress{display:grid;grid-template-columns:105px 1fr;gap:11px 20px;align-items:end}.progress-number strong{font-family:Georgia,serif;font-size:36px;font-weight:500;display:block}.progress-number span{font-size:12px;color:var(--muted)}.progress{height:9px;background:#eceff7;border-radius:9px;overflow:hidden}.progress span{display:block;height:100%;background:linear-gradient(90deg,#0000a5,var(--accent-light));border-radius:9px}.progress-meta{grid-column:2;display:flex;justify-content:space-between;font-size:10.8px;text-transform:uppercase;letter-spacing:.08em;color:#5d6475}.due-list{display:grid}.due-list a{display:grid;grid-template-columns:60px 1fr;text-decoration:none;border-top:1px solid #e8ebf3;padding:10px 0;align-items:center}.due-list a:first-child{border:0;padding-top:0}.due-list time{font-size:12px;color:var(--accent);font-weight:750}.due-list strong,.due-list small{display:block}.due-list strong{font-size:13.2px}.due-list small{font-size:10.8px;color:var(--muted);margin-top:3px}.resource-bars{display:grid;gap:11px}.resource-bars a{display:grid;grid-template-columns:105px 1fr 20px;gap:9px;align-items:center;text-decoration:none;font-size:12px}.resource-bars i{height:5px;background:#e8ebf3;border-radius:5px;overflow:hidden}.resource-bars b{display:block;height:100%;background:#6676dd;border-radius:5px}.resource-bars strong{text-align:right}.catalog{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.catalog a{display:flex;justify-content:space-between;text-decoration:none;padding:9px 11px;background:#f2f4fa;border-radius:6px;font-size:12px}.catalog a:hover{background:var(--accent-soft)}.page-intro{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.page-intro h2,.detail-head h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.page-intro p:not(.kicker){color:var(--muted);max-width:700px;font-size:15.6px;margin:0}.button{border:1px solid #d0d5e3;background:#fff;border-radius:7px;padding:9px 13px;cursor:pointer;font-size:14.4px;font-weight:650}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.danger{color:var(--red)}.list-tools{display:flex;align-items:center;gap:10px;margin-bottom:12px}.list-tools label{flex:1}.list-tools input,.list-tools select{width:100%;border:1px solid var(--line);border-radius:7px;background:#fff;padding:10px 12px;font-size:14.4px}.list-tools select{width:auto}.list-tools>span{color:var(--muted);font-size:12px}.record-table-wrap{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:auto}.record-table{width:100%;border-collapse:collapse;font-size:13.2px}.record-table th{background:#f2f4fa;text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#75817b;font-size:10.8px;padding:11px 14px;border-bottom:1px solid var(--line)}.record-table td{padding:13px 14px;border-bottom:1px solid #e8ebf3;vertical-align:top}.record-table tr:last-child td{border-bottom:0}.record-table code{font-size:10.8px;color:#5d6475}.record-title{display:block;color:var(--ink);font-weight:700;text-decoration:none}.record-table td>small{display:block;color:#6a7181;margin-top:3px}.record-table td[data-label="Description"]{min-width:260px;max-width:520px;color:var(--muted);line-height:1.45}.badge,.tag,.type-pill{display:inline-block;border-radius:99px;background:#eceff7;padding:3px 7px;font-size:10.8px;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.tag{text-transform:none;margin:1px}.badge.status-active,.badge.status-approved,.badge.status-complete,.badge.status-passed,.badge.status-accepted{background:#ddefe5;color:#176143}.badge.status-open,.badge.status-high,.badge.status-critical,.badge.status-failed{background:#f5ded9;color:#8d352c}.badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#f7e9cf;color:#855717}.breadcrumbs{display:flex;gap:8px;color:var(--muted);font-size:13.2px;margin-bottom:20px}.detail-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:22px}.detail-head h2{margin-bottom:4px}.detail-head>div>code{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px}.detail-grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(270px,1fr);gap:14px}.detail-grid aside{display:grid;gap:14px;align-content:start}.detail-main{padding:29px}.content-label{color:#75817b;text-transform:uppercase;letter-spacing:.08em;font-size:10.8px;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:23px}.markdown{max-width:790px}.markdown h1{font-family:Georgia,serif;font-size:34.8px;font-weight:500}.markdown h2{font-family:Georgia,serif;font-size:27.6px;font-weight:500;margin-top:1.8em}.markdown h3{font-size:18px;margin-top:1.7em}.markdown p,.markdown li{font-size:15.6px;line-height:1.65;color:#272c3b}.markdown code{background:#eef0f6;border-radius:3px;padding:1px 4px}.markdown pre{padding:15px;background:#10162b;color:#e8ebff;border-radius:7px;overflow:auto}.markdown blockquote{border-left:3px solid var(--accent-light);padding:4px 15px;color:var(--muted);margin-left:0}.table-wrap{overflow:auto}.markdown table{border-collapse:collapse;width:100%;font-size:13.2px}.markdown th,.markdown td{border:1px solid var(--line);padding:8px;text-align:left}.metadata{margin:0}.metadata>div{display:grid;grid-template-columns:105px 1fr;gap:10px;border-top:1px solid #e8ebf3;padding:10px 0}.metadata>div:first-child{border-top:0;padding-top:0}.metadata dt{font-size:10.8px;text-transform:uppercase;letter-spacing:.06em;color:#5d6475}.metadata dd{margin:0;font-size:13.2px;min-width:0}.compact-json{white-space:pre-wrap;font-size:10.8px}.git-panel>code{font-size:10.8px;word-break:break-all}.git-panel p{font-size:12px;color:var(--muted)}.relation{color:var(--accent);text-decoration:none}.history{display:grid}.history>div{display:grid;grid-template-columns:60px 1fr;gap:8px;padding:8px 0;border-top:1px solid #e8ebf3}.history>div:first-child{border-top:0}.history code{font-size:10.8px;color:var(--accent)}.history strong,.history small{display:block}.history strong{font-size:12px}.history small{font-size:10.8px;color:var(--muted);margin-top:2px}.empty{padding:25px;color:#697184;text-align:center;font-size:13.2px;background:#f4f5fa;border-radius:7px}.changes{padding-left:18px}.changes li{margin:8px 0}.diagnostics>div{display:grid;grid-template-columns:58px minmax(120px,180px) minmax(0,1fr);gap:10px;align-items:start;border-top:1px solid var(--line);padding:10px 0}.diagnostics p{margin:0;font-size:13.2px;overflow-wrap:anywhere}.diagnostics code{font-size:10.8px;overflow-wrap:anywhere}.editor,.search-results{width:min(760px,calc(100vw - 30px));border:0;border-radius:12px;padding:0;box-shadow:0 25px 80px rgba(0,0,24,.28)}dialog::backdrop{background:rgba(0,0,24,.55)}.editor form,.search-results{padding:23px}.dialog-head{display:flex;justify-content:space-between;align-items:start}.dialog-head h2{font-family:Georgia,serif;font-weight:500;margin:5px 0 0}.icon-button{border:0;background:#eceff7;width:32px;height:32px;border-radius:50%;font-size:26.4px;cursor:pointer}.editor form>p{font-size:13.2px;color:var(--muted)}.editor textarea{width:100%;height:440px;border:1px solid var(--line);border-radius:7px;background:#10162b;color:#e8ebff;padding:15px;font:13.2px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;tab-size:2px}.dialog-actions{display:flex;justify-content:end;gap:8px;margin-top:14px}.dialog-error{color:var(--red);font-size:13.2px;min-height:18px;margin-top:7px}.result-list{display:grid;margin-top:17px;max-height:60vh;overflow:auto}.result-list a{display:block;text-decoration:none;padding:11px;border-top:1px solid var(--line)}.result-list strong,.result-list small{display:block}.result-list small{color:var(--muted);margin-top:3px}.muted{color:#737a8b}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
5871
5959
  .topbar-readiness{display:grid;grid-template-columns:minmax(0,1fr);gap:3px;flex:0 1 220px;min-width:155px;padding:5px 8px;border:1px solid transparent;border-radius:8px;color:var(--ink);text-decoration:none}.topbar-readiness:hover{border-color:var(--accent-light);background:var(--accent-soft)}.topbar-readiness-copy{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center}.topbar-readiness-copy>span{overflow:hidden;color:var(--muted);font-size:9.6px;font-weight:700;text-overflow:ellipsis;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.topbar-readiness-copy>strong{font-size:10.8px;line-height:1}.topbar-readiness>.progress{width:100%;height:5px}.topbar-status{display:flex;flex:1 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:8px;margin-left:auto}.program-select select{max-width:180px;border:1px solid var(--line);border-radius:8px;padding:9px 28px 9px 10px;background:var(--surface);color:var(--ink);font:inherit}.topbar-status .topbar-search{flex:0 1 240px;width:240px;min-width:140px;margin-left:0}.repo-chip,.validation-chip{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:8px;padding:10px 12px;color:var(--muted);font-size:13.2px;white-space:nowrap;text-decoration:none}.repo-chip:hover,.validation-chip:hover{color:var(--ink);border-color:var(--accent-light)}
5872
- .metadata>div{grid-template-columns:minmax(140px,1fr) minmax(0,2.5fr)}.metadata dt{min-width:0;overflow-wrap:anywhere}
5960
+ .metadata>div{grid-template-columns:minmax(140px,1fr) minmax(0,2.5fr)}.metadata dt{min-width:0;overflow-wrap:anywhere}.editor textarea{tab-size:2}
5961
+ .is-loading>.spinner,.state-loading>.spinner{align-self:center;margin-top:0}.state-loading,.repository-sync-alert.is-loading{align-items:center}.topbar-readiness.is-loading .topbar-readiness-copy>strong{display:flex;align-items:center;justify-content:center}
5962
+ @media(prefers-reduced-motion:reduce){.spinner{animation:none}}
5873
5963
  .audit-progress-empty{padding:11px 13px;border-radius:8px;background:var(--surface-soft)}.audit-progress-empty strong,.audit-progress-empty span{display:block}.audit-progress-empty strong{font-size:13.2px}.audit-progress-empty span{margin-top:4px;color:var(--muted);font-size:10.8px}
5874
5964
  .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)}
5875
5965
  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}