filegrc 0.13.1 → 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.
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 "";
@@ -938,15 +980,26 @@ function openCollectionReviewDialog(type) {
938
980
  const sourceType = v4 ? "component" : "system";
939
981
  const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
940
982
  const allowedDecisions = configuration.decisions || ["complete"];
941
- const defaultDecision = assessment.review?.decision
942
- || (!assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0]);
983
+ const priorDecision = assessment.review?.decision;
984
+ const priorAuthoritativeSourceId = assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId || null;
985
+ const priorAuthoritativeSourceActive = priorDecision !== "externally-managed"
986
+ || systems.some(({ record }) => record.id === priorAuthoritativeSourceId);
987
+ const priorDecisionStillValid = allowedDecisions.includes(priorDecision)
988
+ && priorAuthoritativeSourceActive
989
+ && (assessment.recordCount ? priorDecision !== "zero-population" : priorDecision !== "complete");
990
+ const preservedAuthoritativeSourceId = priorDecisionStillValid && priorDecision === "externally-managed"
991
+ ? priorAuthoritativeSourceId
992
+ : null;
993
+ const defaultDecision = priorDecisionStillValid
994
+ ? priorDecision
995
+ : !assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0];
943
996
  const decisions = allowedDecisions.map((decision) => (
944
997
  '<option value="' + esc(decision) + '" ' + (defaultDecision === decision ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>'
945
998
  )).join("");
946
999
  const dialog = document.createElement("dialog");
947
1000
  dialog.className = "commit-dialog event-dialog collection-review-dialog";
948
1001
  dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
949
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(assessment.review?.reviewedOn || currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId) === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>Confirm ' + assessment.recordCount + ' current ' + esc(pluralize("record", assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Confirm and save</button></div></form>';
1002
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>Confirm ' + assessment.recordCount + ' current ' + esc(pluralize("record", assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Confirm and save</button></div></form>';
950
1003
  document.body.append(dialog);
951
1004
  dialog.showModal();
952
1005
  const form = dialog.querySelector("form");
@@ -981,8 +1034,10 @@ function openCollectionReviewDialog(type) {
981
1034
  expectedRevision: assessment.reviewRevision || undefined
982
1035
  };
983
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…");
984
1039
  try {
985
- saveStatus.textContent = "Validating and saving…";
1040
+ setLoadingContent(saveStatus, "Validating and saving…");
986
1041
  const prefetch = await repositoryPrefetch;
987
1042
  if (prefetch?.error) throw prefetch.error;
988
1043
  const response = await localFetch("/api/collection-review", {
@@ -991,14 +1046,15 @@ function openCollectionReviewDialog(type) {
991
1046
  body: JSON.stringify({ ...payload, confirmed: true, prefetchToken: prefetch?.token })
992
1047
  });
993
1048
  if (!response.ok) throw new Error(await responseMessage(response));
994
- saveStatus.textContent = "Saved locally. Refreshing page…";
1049
+ setLoadingContent(saveStatus, "Saved locally. Refreshing page…");
995
1050
  applyMutationState(await response.json());
996
1051
  dialog.close();
997
1052
  render();
998
1053
  } catch (requestError) {
999
- saveStatus.textContent = "Not saved";
1054
+ clearLoadingContent(saveStatus, "Not saved");
1000
1055
  error.textContent = requestError.message;
1001
1056
  } finally {
1057
+ setButtonBusy(submit, false);
1002
1058
  form.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = false; });
1003
1059
  }
1004
1060
  });
@@ -1909,8 +1965,7 @@ async function openObligationRuleActivation(ruleId) {
1909
1965
  const approvedByIds = [...form.querySelectorAll('input[name="approver"]:checked')].map(({ value }) => value);
1910
1966
  if (!approvedByIds.length) return void (error.textContent = "Select at least one approver.");
1911
1967
  const button = form.querySelector('button[type="submit"]');
1912
- button.disabled = true;
1913
- button.textContent = "Activating…";
1968
+ setButtonBusy(button, true, "Activating…");
1914
1969
  error.textContent = "";
1915
1970
  try {
1916
1971
  const saved = await localFetch("/api/obligation-rule-activations", {
@@ -1931,8 +1986,7 @@ async function openObligationRuleActivation(ruleId) {
1931
1986
  render();
1932
1987
  } catch (caught) {
1933
1988
  error.textContent = caught.message;
1934
- button.disabled = false;
1935
- button.textContent = "Activate schedule";
1989
+ setButtonBusy(button, false);
1936
1990
  }
1937
1991
  });
1938
1992
  } catch (error) {
@@ -2068,6 +2122,8 @@ function openObligationEventDialog(trigger) {
2068
2122
  const form = event.currentTarget;
2069
2123
  if (!form.reportValidity()) return;
2070
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…");
2071
2127
  try {
2072
2128
  const response = await localFetch("/api/obligation-events", {
2073
2129
  method: "POST",
@@ -2092,6 +2148,7 @@ function openObligationEventDialog(trigger) {
2092
2148
  history.replaceState(null, "", "#/stage/run");
2093
2149
  render();
2094
2150
  } catch (error) {
2151
+ setButtonBusy(submit, false);
2095
2152
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
2096
2153
  form.querySelector(".dialog-error").textContent = error.message;
2097
2154
  }
@@ -2124,6 +2181,8 @@ function openExternalReviewerGovernanceDialog() {
2124
2181
  const error = dialog.querySelector(".dialog-error");
2125
2182
  error.textContent = "";
2126
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…");
2127
2186
  try {
2128
2187
  if (!previewedPayload) {
2129
2188
  const response = await localFetch("/api/external-reviewer-governance/preview", {
@@ -2150,6 +2209,8 @@ function openExternalReviewerGovernanceDialog() {
2150
2209
  } catch (requestError) {
2151
2210
  error.textContent = requestError.message;
2152
2211
  } finally {
2212
+ setButtonBusy(submit, false);
2213
+ submit.textContent = previewedPayload ? "Confirm and apply" : "Preview bundle";
2153
2214
  form.querySelectorAll("button,input,textarea").forEach((control) => { control.disabled = false; });
2154
2215
  }
2155
2216
  });
@@ -2186,6 +2247,8 @@ function openNextAuditCycleDialog(prior) {
2186
2247
  const error = dialog.querySelector(".dialog-error");
2187
2248
  error.textContent = "";
2188
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…");
2189
2252
  try {
2190
2253
  if (!previewedPayload) {
2191
2254
  const response = await localFetch("/api/audit-cycle/preview", {
@@ -2214,6 +2277,8 @@ function openNextAuditCycleDialog(prior) {
2214
2277
  } catch (requestError) {
2215
2278
  error.textContent = requestError.message;
2216
2279
  } finally {
2280
+ setButtonBusy(submit, false);
2281
+ submit.textContent = previewedPayload ? "Confirm and create" : "Preview carry-forward";
2217
2282
  form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
2218
2283
  }
2219
2284
  });
@@ -2303,6 +2368,8 @@ function openApplicabilityReviewDialog(type, entries) {
2303
2368
  ])
2304
2369
  };
2305
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…");
2306
2373
  try {
2307
2374
  if (!previewedPayload) {
2308
2375
  const response = await localFetch("/api/applicability-review/preview", {
@@ -2316,7 +2383,7 @@ function openApplicabilityReviewDialog(type, entries) {
2316
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>';
2317
2384
  dialog.querySelector("[data-preview-review]").textContent = "Confirm and save";
2318
2385
  } else {
2319
- saveStatus.textContent = "Validating and saving…";
2386
+ setLoadingContent(saveStatus, "Validating and saving…");
2320
2387
  const prefetch = await repositoryPrefetch;
2321
2388
  if (prefetch?.error) throw prefetch.error;
2322
2389
  const response = await localFetch("/api/applicability-review", {
@@ -2325,14 +2392,17 @@ function openApplicabilityReviewDialog(type, entries) {
2325
2392
  body: JSON.stringify({ ...previewedPayload, confirmed: true, prefetchToken: prefetch?.token })
2326
2393
  });
2327
2394
  if (!response.ok) throw new Error(await responseMessage(response));
2328
- saveStatus.textContent = "Saved locally. Refreshing page…";
2395
+ setLoadingContent(saveStatus, "Saved locally. Refreshing page…");
2329
2396
  applyMutationState(await response.json());
2330
2397
  dialog.close();
2331
2398
  render();
2332
2399
  }
2333
2400
  } catch (requestError) {
2401
+ clearLoadingContent(saveStatus, "Not saved");
2334
2402
  error.textContent = requestError.message;
2335
2403
  } finally {
2404
+ setButtonBusy(submit, false);
2405
+ submit.textContent = previewedPayload ? "Confirm and save" : "Preview decisions";
2336
2406
  form.querySelectorAll("button,input,select").forEach((control) => { control.disabled = false; });
2337
2407
  }
2338
2408
  });
@@ -2377,8 +2447,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2377
2447
  main.querySelector("#initialize-audit-work")?.addEventListener("click", async (event) => {
2378
2448
  const button = event.currentTarget;
2379
2449
  const error = main.querySelector(".audit-preparation-error");
2380
- button.disabled = true;
2381
- button.textContent = "Initializing…";
2450
+ setButtonBusy(button, true, "Initializing…");
2382
2451
  error.textContent = "";
2383
2452
  try {
2384
2453
  const response = await localFetch("/api/audit-preparation", {
@@ -2391,8 +2460,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2391
2460
  render();
2392
2461
  } catch (caught) {
2393
2462
  error.textContent = caught.message;
2394
- button.disabled = false;
2395
- button.textContent = "Initialize audit work";
2463
+ setButtonBusy(button, false);
2396
2464
  }
2397
2465
  });
2398
2466
  main.querySelector("#packet-form").addEventListener("submit", async (event) => {
@@ -2401,8 +2469,7 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2401
2469
  if (!form.reportValidity()) return;
2402
2470
  const button = form.querySelector('button[type="submit"]');
2403
2471
  const error = main.querySelector(".dialog-error");
2404
- button.disabled = true;
2405
- button.textContent = "Generating…";
2472
+ setButtonBusy(button, true, "Generating…");
2406
2473
  error.textContent = "";
2407
2474
  try {
2408
2475
  const response = await localFetch("/api/evidence-packet", {
@@ -2423,8 +2490,8 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
2423
2490
  } catch (caught) {
2424
2491
  error.textContent = caught.message;
2425
2492
  } finally {
2493
+ setButtonBusy(button, false);
2426
2494
  button.disabled = state.readOnly;
2427
- button.textContent = draft ? "Generate draft" : "Generate packet";
2428
2495
  }
2429
2496
  });
2430
2497
  if (latestPacketResult && latestPacketState === state && latestPacketResult.packet.audit?.id === selected?.id) {
@@ -2696,7 +2763,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2696
2763
  const definition = state.model.resources[type];
2697
2764
  if (!entry || !definition) return renderNotFound(main);
2698
2765
  if (entry.detailsLoaded === false) {
2699
- 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>';
2700
2767
  loadResourceDetail(type, id);
2701
2768
  return;
2702
2769
  }
@@ -2768,8 +2835,10 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
2768
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>'
2769
2836
  : "";
2770
2837
  const participationPanel = entry.historyLoaded === false
2771
- ? '<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>'
2772
- : 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);
2773
2842
  const supportPanels = renderDetailSupport({
2774
2843
  hasRecordBody,
2775
2844
  workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
@@ -2996,8 +3065,8 @@ async function loadResourceHistory(type, id) {
2996
3065
  const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
2997
3066
  if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
2998
3067
  const request = (async () => {
3068
+ const token = state.stateToken;
2999
3069
  try {
3000
- const token = state.stateToken;
3001
3070
  const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
3002
3071
  const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
3003
3072
  if (response.status === 409 && token) {
@@ -3018,8 +3087,15 @@ async function loadResourceHistory(type, id) {
3018
3087
  entry.historyLoaded = true;
3019
3088
  const route = parseRoute();
3020
3089
  if (route.name === "detail" && route.type === type && route.id === id) render();
3021
- } catch {
3022
- // 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();
3023
3099
  } finally {
3024
3100
  for (const [requestKey, pending] of resourceDetailRequests) {
3025
3101
  if (pending === request) resourceDetailRequests.delete(requestKey);
@@ -3412,8 +3488,8 @@ async function runRepositoryGitAction(action) {
3412
3488
  status.textContent = "";
3413
3489
  status.classList.remove("error");
3414
3490
  }
3491
+ if (active) setButtonBusy(active, true, label);
3415
3492
  buttons.forEach((button) => { button.disabled = true; });
3416
- if (active) active.textContent = label;
3417
3493
  try {
3418
3494
  const response = await localFetch("/api/git/" + action, { method: "POST" });
3419
3495
  if (!response.ok) throw new Error(await responseMessage(response));
@@ -3429,8 +3505,8 @@ async function runRepositoryGitAction(action) {
3429
3505
  ? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
3430
3506
  : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
3431
3507
  } catch (cause) {
3508
+ if (active) setButtonBusy(active, false);
3432
3509
  buttons.forEach((button, index) => { button.disabled = disabled[index]; });
3433
- if (active) active.textContent = action === "pull" ? "Check incoming commits" : action === "retry-sync" ? "Retry sync" : "Push";
3434
3510
  if (status) {
3435
3511
  status.textContent = cause.message;
3436
3512
  status.classList.add("error");
@@ -3456,8 +3532,10 @@ function openCommitDialog() {
3456
3532
  const form = event.currentTarget;
3457
3533
  if (!form.reportValidity()) return;
3458
3534
  const button = form.querySelector('button[type="submit"]');
3459
- form.querySelectorAll("button,input").forEach((control) => { control.disabled = true; });
3460
- 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; });
3461
3539
  try {
3462
3540
  const response = await localFetch("/api/commit", {
3463
3541
  method: "POST",
@@ -3479,8 +3557,8 @@ function openCommitDialog() {
3479
3557
  status.classList.toggle("error", !result.pushed && !result.pushSkipped);
3480
3558
  }
3481
3559
  } catch (error) {
3482
- form.querySelectorAll("button,input").forEach((control) => { control.disabled = false; });
3483
- button.textContent = actionLabel;
3560
+ setButtonBusy(button, false);
3561
+ controls.forEach((control, index) => { control.disabled = disabled[index]; });
3484
3562
  form.querySelector(".dialog-error").textContent = error.message;
3485
3563
  }
3486
3564
  });
@@ -4061,9 +4139,11 @@ function setOnboardingBusy(busy, label = "") {
4061
4139
  onboardingBusy = busy;
4062
4140
  onboardingDialog.querySelectorAll("button,input,select,textarea").forEach((control) => { control.disabled = busy; });
4063
4141
  const next = onboardingDialog.querySelector('[data-onboarding="next"]');
4064
- if (next && label) next.textContent = label;
4142
+ const draft = onboardingDialog.querySelector('[data-onboarding="draft"]');
4065
4143
  const skip = onboardingDialog.querySelector('[data-onboarding="skip"]');
4066
- 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);
4067
4147
  if (busy && onboardingStep === onboardingSteps().length - 1) {
4068
4148
  onboardingStillWorkingTimer = setTimeout(() => {
4069
4149
  const status = onboardingDialog?.querySelector(".onboarding-save-status");
@@ -4743,12 +4823,8 @@ function wireStructuredObjectEditors(dialog) {
4743
4823
  dialog.querySelector("form").addEventListener("click", (event) => {
4744
4824
  const bind = event.target.closest("[data-bind-review-revisions]");
4745
4825
  if (bind) {
4746
- const previousLabel = bind.textContent;
4747
4826
  bindCurrentReviewRevisions(dialog, bind).catch((error) => {
4748
4827
  dialog.querySelector(".dialog-error").textContent = error.message;
4749
- }).finally(() => {
4750
- bind.disabled = false;
4751
- if (bind.textContent === "Binding…") bind.textContent = previousLabel;
4752
4828
  });
4753
4829
  return;
4754
4830
  }
@@ -4824,23 +4900,28 @@ async function bindCurrentReviewRevisions(dialog, button) {
4824
4900
  throw new Error("Select the retention schedule, Information Types, and operational scope before binding their current revisions.");
4825
4901
  }
4826
4902
  const ids = [...new Set(Object.values(selected).flat().concat(scheduleDocumentId, retentionUseReviewIds(dialog)).filter(Boolean))];
4827
- button.disabled = true;
4828
- button.textContent = "Binding…";
4829
- const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
4830
- const response = await localFetch("/api/review-revisions?" + query);
4831
- if (!response.ok) throw new Error(await responseMessage(response));
4832
- const { revisions } = await response.json();
4833
- const editor = button.closest(".string-map-editor");
4834
- const items = editor.querySelector(".string-map-items");
4835
- items.replaceChildren();
4836
- for (const [key, value] of Object.entries(revisions)) {
4837
- items.append(editor.querySelector("template").content.cloneNode(true));
4838
- const row = items.lastElementChild;
4839
- row.querySelector("[data-map-key]").value = key;
4840
- 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";
4841
4924
  }
4842
- button.textContent = "Refresh current revisions";
4843
- dialog.querySelector(".dialog-error").textContent = "";
4844
4925
  }
4845
4926
 
4846
4927
  function retentionUseReviewIds(dialog) {
@@ -5687,16 +5768,16 @@ function prefetchRepositoryForReview(status = null) {
5687
5768
  if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
5688
5769
  return Promise.resolve(null);
5689
5770
  }
5690
- if (status) status.textContent = "Checking repository…";
5771
+ if (status) setLoadingContent(status, "Checking repository…");
5691
5772
  return fetch("/api/git/prefetch", { method: "POST" })
5692
5773
  .then(async (response) => {
5693
5774
  if (!response.ok) throw new Error(await responseMessage(response));
5694
5775
  const result = await response.json();
5695
- 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");
5696
5777
  return result;
5697
5778
  })
5698
5779
  .catch((error) => {
5699
- if (status) status.textContent = "Repository check failed";
5780
+ if (status) clearLoadingContent(status, "Repository check failed");
5700
5781
  return null;
5701
5782
  });
5702
5783
  }
@@ -5745,7 +5826,10 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
5745
5826
  clearTimeout(dialog._stillWorkingTimer);
5746
5827
  dialog._stillWorkingTimer = null;
5747
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);
5748
5831
  dialog.querySelectorAll("button,input,select,textarea").forEach((control) => {
5832
+ if (control === button) return;
5749
5833
  if (busy) {
5750
5834
  control.dataset.mutationWasDisabled = control.disabled ? "true" : "false";
5751
5835
  control.disabled = true;
@@ -5754,13 +5838,15 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
5754
5838
  delete control.dataset.mutationWasDisabled;
5755
5839
  }
5756
5840
  });
5757
- const button = dialog.querySelector("#save-record,#save-content,#activate-policy");
5758
- if (button) button.textContent = busy ? label : idleLabel;
5841
+ if (!busy && button) {
5842
+ setButtonBusy(button, false);
5843
+ if (idleLabel) button.textContent = idleLabel;
5844
+ }
5759
5845
  const status = dialog.querySelector(".save-status");
5760
- if (status) status.textContent = "";
5846
+ if (status) clearLoadingContent(status);
5761
5847
  if (busy) {
5762
5848
  dialog._stillWorkingTimer = setTimeout(() => {
5763
- if (status) status.textContent = "Still working. Recalculating readiness and repository state.";
5849
+ if (status) setLoadingContent(status, "Still working. Recalculating readiness and repository state.");
5764
5850
  }, 1_500);
5765
5851
  }
5766
5852
  }
@@ -5804,6 +5890,8 @@ async function responseMessage(response) {
5804
5890
  try { return JSON.parse(source).error || source; } catch { return source; }
5805
5891
  }
5806
5892
  async function localFetch(url, options) {
5893
+ const requestProgramId = state?.selectedProgramId || null;
5894
+ const requestProgramGeneration = programSelectionGeneration;
5807
5895
  const requestUrl = new URL(url, location.origin);
5808
5896
  if (state?.selectedProgramId && !requestUrl.searchParams.has("programId")) {
5809
5897
  requestUrl.searchParams.set("programId", state.selectedProgramId);
@@ -5826,17 +5914,24 @@ async function localFetch(url, options) {
5826
5914
  const chip = synchronizing ? document.querySelector(".repo-chip") : null;
5827
5915
  const previousChip = chip?.innerHTML;
5828
5916
  let repositoryRefreshed = false;
5829
- 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
+ }
5830
5922
  try {
5831
5923
  const response = await fetch(scopedUrl, requestOptions);
5832
5924
  if (synchronizing && !response.ok) {
5833
5925
  try {
5834
- const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
5926
+ const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(requestProgramId || ""));
5835
5927
  if (stateResponse.ok) {
5836
- 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();
5837
5933
  if (chip?.isConnected) {
5838
5934
  chip.innerHTML = '<span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span>' + esc(state.repository.label);
5839
- repositoryRefreshed = true;
5840
5935
  }
5841
5936
  }
5842
5937
  } catch {
@@ -5847,7 +5942,11 @@ async function localFetch(url, options) {
5847
5942
  } catch {
5848
5943
  throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
5849
5944
  } finally {
5850
- 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
+ }
5851
5950
  }
5852
5951
  }
5853
5952
  async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
@@ -5856,9 +5955,11 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
5856
5955
 
5857
5956
  export const APP_STYLES = String.raw`
5858
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}
5859
- *{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}
5860
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)}
5861
- .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}}
5862
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}
5863
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)}
5864
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}