filegrc 0.16.22 → 0.16.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.22",
3
+ "version": "0.16.24",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,6 +3,7 @@ import { modelSupports } from "../model/index.js";
3
3
  import {
4
4
  collectionRevision,
5
5
  collectionReviewDecisionMatches,
6
+ controlReviewerConflictIds,
6
7
  reviewedControlConflictIds
7
8
  } from "./collection-revision.js";
8
9
  import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
@@ -433,15 +434,7 @@ async function requireControlReviewReady(loaded, resourceType, programId) {
433
434
  if (resourceType !== "control") return;
434
435
  const { assessProgramReadiness } = await import("./program-readiness.js");
435
436
  const readiness = await assessProgramReadiness(loaded, { programId });
436
- const earlierItems = readiness.stages
437
- .find(({ id }) => id === "controls")
438
- ?.items.filter(({ id }) => (
439
- id !== "collection-review-control"
440
- && !id.startsWith("document-activation-")
441
- && !id.startsWith("training-activation-")
442
- && !id.startsWith("policy-activation-")
443
- )) || [];
444
- if (!earlierItems.length || earlierItems.some(({ status }) => status !== "complete")) {
437
+ if (!readiness.controlReviewReady) {
445
438
  throw new Error("Complete the Step 3 implementation work before recording the Control collection review. Program content activation follows this review.");
446
439
  }
447
440
  }
@@ -458,19 +451,7 @@ async function requireRetentionScheduleReady(loaded, resourceType, program, rete
458
451
  }
459
452
 
460
453
  function assessControlReviewers(loaded, controls) {
461
- const byId = new Map(loaded.resources.map((record) => [record.id, record]));
462
- const controlIds = new Set(controls.map(({ id }) => id));
463
- const conflictIds = new Set();
464
- for (const control of controls) {
465
- for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
466
- }
467
- for (const obligation of loaded.resources.filter((record) => (
468
- record.type === "obligation"
469
- && record.status === "active"
470
- && (record.controlIds || []).some((id) => controlIds.has(id))
471
- ))) {
472
- for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
473
- }
454
+ const conflictIds = new Set(controlReviewerConflictIds(loaded, controls));
474
455
  const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
475
456
  return {
476
457
  eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
@@ -128,14 +128,21 @@ export function reviewedControlConflictIds(loaded, review) {
128
128
  const controls = scopedCollectionRecords(source, "control", program);
129
129
  const controlIds = new Set(controls.map(({ id }) => id));
130
130
  const byId = new Map(source.resources.map((record) => [record.id, record]));
131
+ return controlReviewerConflictIds(source, controls, byId, controlIds);
132
+ }
133
+
134
+ export function controlReviewerConflictIds(loaded, controls, byId = new Map(loaded.resources.map((record) => [record.id, record])), controlIds = new Set(controls.map(({ id }) => id))) {
131
135
  const conflicts = new Set();
132
- for (const record of [
133
- ...controls,
134
- ...source.resources.filter((record) => record.type === "obligation"
135
- && record.status === "active"
136
- && (record.controlIds || []).some((id) => controlIds.has(id)))
137
- ]) {
138
- for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflicts.add(id);
136
+ for (const control of controls) {
137
+ for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflicts.add(id);
138
+ }
139
+ for (const obligation of loaded.resources.filter((record) => record.type === "obligation"
140
+ && record.status === "active"
141
+ && (record.controlIds || []).some((id) => controlIds.has(id)))) {
142
+ // A team owns the work queue, but membership alone does not mean each member
143
+ // performed its work. Named people and appointment holders remain conflicts.
144
+ const namedOwners = (obligation.ownerIds || []).filter((id) => byId.get(id)?.type !== "team");
145
+ for (const id of currentPartyPeople(namedOwners, byId)) conflicts.add(id);
139
146
  }
140
147
  return [...conflicts].sort();
141
148
  }
@@ -203,6 +203,7 @@ export async function assessProgramReadiness(input, options = {}) {
203
203
  asOfTime
204
204
  );
205
205
  const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
206
+ const controlReviewPrerequisiteIds = controlStage.items.map(({ id }) => id);
206
207
  const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
207
208
  if (oversightEligible) {
208
209
  controlStage.items.push(collectionReviewReadinessItem(controlOversight));
@@ -331,6 +332,8 @@ export async function assessProgramReadiness(input, options = {}) {
331
332
  canStartCandidatePeriod,
332
333
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
333
334
  policyActivations,
335
+ controlReviewReady: oversightEligible,
336
+ controlReviewPrerequisiteIds,
334
337
  documentActivations: governedContent.documentActivations,
335
338
  trainingActivations: governedContent.trainingActivations,
336
339
  policyLibraryProposals: [
package/src/web.js CHANGED
@@ -650,6 +650,9 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
650
650
  });
651
651
  main.querySelector("[data-review-policy-activation]")?.addEventListener("click", openPolicyActivationDialog);
652
652
  main.querySelector("[data-review-document-activation]")?.addEventListener("click", () => openDocumentActivationDialog());
653
+ if (stage.id === "controls") {
654
+ main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("control"));
655
+ }
653
656
  main.querySelector("[data-review-audit-document-activation]")?.addEventListener("click", (event) => {
654
657
  openDocumentActivationDialog(event.currentTarget.dataset.reviewAuditDocumentActivation);
655
658
  });
@@ -1263,7 +1266,10 @@ function collectionReviewPanel(type, force = false) {
1263
1266
  const proposalBlocker = !current && assessment.incompleteRecordProposals?.length
1264
1267
  ? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
1265
1268
  : null;
1266
- const reviewBlocker = scheduleBlocker || proposalBlocker;
1269
+ const controlBlocker = type === "control" && !state.programReadiness?.controlReviewReady
1270
+ ? "Complete the Control implementation checks listed in Step 3 before this review."
1271
+ : null;
1272
+ const reviewBlocker = scheduleBlocker || proposalBlocker || controlBlocker;
1267
1273
  const needsFirstRecord = collectionNeedsFirstRecord(type);
1268
1274
  const reviewerNames = (assessment.review?.reviewedByIds || [])
1269
1275
  .map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
@@ -1322,27 +1328,29 @@ function collectionReviewVisible(type) {
1322
1328
  }
1323
1329
 
1324
1330
  function renderFinishStepThree() {
1325
- const stage = state.programReadiness?.stages?.find(({ id }) => id === "controls");
1331
+ const readiness = state.programReadiness;
1332
+ const stage = readiness?.stages?.find(({ id }) => id === "controls");
1326
1333
  const items = stage?.items || [];
1327
1334
  const oversightItem = items.find(({ id }) => id === "collection-review-control");
1328
1335
  const oversight = state.collectionReviews?.control;
1329
- const activationItems = items.filter(({ id }) => (
1330
- id.startsWith("document-activation-")
1331
- || id.startsWith("training-activation-")
1332
- || id.startsWith("policy-activation-")
1333
- ));
1334
- const implementationItems = items.filter(({ id }) => (
1335
- id !== "collection-review-control"
1336
- && !id.startsWith("document-activation-")
1337
- && !id.startsWith("training-activation-")
1338
- && !id.startsWith("policy-activation-")
1339
- ));
1340
- const next = implementationItems.find(({ status }) => status !== "complete");
1341
- if (!oversightItem) {
1336
+ const prerequisiteIds = new Set(readiness?.controlReviewPrerequisiteIds || []);
1337
+ const blockers = items.filter(({ id, status }) => prerequisiteIds.has(id) && status !== "complete");
1338
+ const activationItems = items.filter(({ id }) => id !== "collection-review-control" && !prerequisiteIds.has(id));
1339
+ if (!readiness?.controlReviewReady || !oversightItem) {
1340
+ const next = blockers[0];
1342
1341
  const href = next?.id?.startsWith("source-family-")
1343
1342
  ? "#/evidence-sources"
1344
1343
  : next ? workflowItemHref({ ...next, state: next.status }) || "#/resources/control" : "#/resources/control";
1345
- return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(next?.message || "Finish the Step 3 work areas before the final review becomes available.") + '</p></div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
1344
+ const blockerRows = blockers.map((item) => {
1345
+ const target = item.id.startsWith("source-family-") ? "#/evidence-sources"
1346
+ : workflowItemHref({ ...item, state: item.status }) || "#/resources/control";
1347
+ const resolution = item.nextSteps?.length ? item.nextSteps.join(" ")
1348
+ : item.id.startsWith("source-family-")
1349
+ ? item.message + " Open Evidence Sources to assign a matching active source, access owners, and extraction instructions for every listed Control."
1350
+ : item.message;
1351
+ return '<li><a href="' + esc(target) + '"><strong>' + esc(item.title) + '</strong></a><p>' + esc(resolution) + '</p></li>';
1352
+ }).join("");
1353
+ return '<section class="finish-step panel blocked"><div><p class="kicker">Finish Step 3</p><h2>Complete implementation first</h2><p>' + esc(blockers.length ? blockers.length + " implementation " + pluralize("blocker", blockers.length) + " remain before the Control collection review." : "Finish the Step 3 work areas before the final review becomes available.") + '</p>' + (blockerRows ? '<details class="control-review-blockers"><summary>Review every blocker and its resolution</summary><ol>' + blockerRows + '</ol></details>' : "") + '</div><a class="button primary" href="' + esc(href) + '">Continue implementation</a></section>';
1346
1354
  }
1347
1355
  if (!oversight?.complete) {
1348
1356
  return '<section class="finish-step-heading"><p class="kicker">Finish Step 3</p><h2>Review the implemented Control collection</h2><p>One eligible reviewer confirms the implemented Controls as a batch. Management activates the approved program content after this review.</p></section>' + collectionReviewPanel("control", true);
@@ -1868,7 +1876,12 @@ function stagePageCard(stage, destination, index) {
1868
1876
  return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
1869
1877
  }).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
1870
1878
  : "";
1871
- return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><div class="stage-page-card-actions">' + completionState + '<a class="stage-page-open" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"><span>Open ›</span></a></div></div><p>' + esc(summary) + '</p>' + taskPreview + '</article>';
1879
+ const proposals = stage.id === "controls" && destination.type === "obligation"
1880
+ ? stepThreeObligationProposals().length : 0;
1881
+ const proposalNote = proposals
1882
+ ? '<p class="stage-page-proposals">' + proposals + ' proposed ' + pluralize("schedule", proposals) + ' to review. These proposals do not each block Control implementation.</p>'
1883
+ : "";
1884
+ return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><div class="stage-page-card-actions">' + completionState + '<a class="stage-page-open" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"><span>Open ›</span></a></div></div><p>' + esc(summary) + '</p>' + taskPreview + proposalNote + '</article>';
1872
1885
  }
1873
1886
 
1874
1887
  function stagePageItemDetail(item) {
@@ -1957,6 +1970,19 @@ function derivedStagePageState(stage, destination) {
1957
1970
  }
1958
1971
 
1959
1972
  function stagePageItems(stage, destination) {
1973
+ if (stage.id === "controls" && destination.type === "obligation") {
1974
+ return (state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items || [])
1975
+ .filter(({ id, checks }) => id.startsWith("control-") && checks?.workQueue === false)
1976
+ .map((item) => ({
1977
+ ...item,
1978
+ key: item.id + "-work-queue",
1979
+ state: "ready",
1980
+ implementationState: null,
1981
+ title: "Enable a work schedule for " + item.title,
1982
+ message: "Review the linked Obligation proposals and enable a rule that matches this Control's operation pattern before marking it Implemented.",
1983
+ subject: { type: "control", id: item.resourceId }
1984
+ }));
1985
+ }
1960
1986
  const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
1961
1987
  const items = [
1962
1988
  ...(state.workflow?.findings || []),
@@ -2000,6 +2026,22 @@ function stagePageItems(stage, destination) {
2000
2026
  ));
2001
2027
  }
2002
2028
 
2029
+ function stepThreeObligationProposals() {
2030
+ const selectedControlIds = new Set(state.programReadiness?.scope?.controlIds || []);
2031
+ const proposedIds = new Set(state.resources.filter(({ record }) => (
2032
+ record.type === "obligation"
2033
+ && record.status === "proposed"
2034
+ && (record.controlIds || []).some((id) => selectedControlIds.has(id))
2035
+ ))
2036
+ .map(({ record }) => record.id));
2037
+ return (state.workflow?.findings || []).filter((item) => (
2038
+ item.stage === "controls"
2039
+ && item.subject?.type === "obligation"
2040
+ && proposedIds.has(item.subject.id)
2041
+ && item.key?.endsWith(".finalize")
2042
+ ));
2043
+ }
2044
+
2003
2045
  function workflowUiStage(stageId) {
2004
2046
  return ["operate", "operation"].includes(stageId) ? "run" : stageId;
2005
2047
  }
@@ -6618,6 +6660,7 @@ function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (character)
6618
6660
 
6619
6661
  export const APP_STYLES = String.raw`
6620
6662
  .finish-step{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:24px;padding:20px 22px}.finish-step h2,.finish-step-heading h2{margin:5px 0 7px;font:500 24px Georgia,serif}.finish-step p:not(.kicker),.finish-step-heading p:not(.kicker){margin:0;color:var(--muted);font-size:12px;line-height:1.55}.finish-step.blocked{border-color:#d8bd78}.finish-step.complete{border-color:#b9dac6}.finish-step-heading{margin-top:28px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}
6663
+ .finish-step.blocked>div{min-width:0;flex:1}.control-review-blockers{margin-top:13px}.control-review-blockers summary{cursor:pointer;color:var(--accent);font-size:11px;font-weight:700}.control-review-blockers ol{max-height:360px;overflow:auto;margin:10px 0 0;padding:0 14px 0 28px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.control-review-blockers li{padding:11px 0;font-size:11px}.control-review-blockers li+li{border-top:1px solid var(--line)}.control-review-blockers li a{color:var(--accent)}.control-review-blockers li p{margin-top:4px!important}.stage-page-proposals{margin-top:auto!important;padding-top:12px;border-top:1px solid var(--line)}
6621
6664
  :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}
6622
6665
  *{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}
6623
6666
  .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)}