@crvouga/atlas 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -0
  3. package/dist/reports/src/catalog-health.d.ts +142 -0
  4. package/dist/reports/src/catalog-health.js +257 -0
  5. package/dist/reports/src/test-catalog.d.ts +8 -0
  6. package/dist/reports/src/test-catalog.js +5 -1
  7. package/dist/schema/src/api.d.ts +79 -0
  8. package/dist/schema/src/api.js +50 -0
  9. package/dist/server/src/app.js +9 -0
  10. package/dist/server/src/llms.js +1 -0
  11. package/dist/server/src/openapi.js +18 -2
  12. package/dist/server/src/settings-page.d.ts +4 -4
  13. package/dist/server/src/settings-page.js +17 -78
  14. package/docs/reference/http-api.md +34 -0
  15. package/docs/reference/reports.md +224 -0
  16. package/docs/reference/schema.md +10 -0
  17. package/docs/reference/server.md +4 -4
  18. package/docs/server.md +10 -0
  19. package/docs/ui.md +142 -0
  20. package/docs/workspaces.md +130 -0
  21. package/llms-full.txt +322 -0
  22. package/llms.txt +2 -0
  23. package/package.json +17 -10
  24. package/packages/schema/schemas/catalog-health.schema.json +400 -0
  25. package/packages/server/openapi.json +596 -0
  26. package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
  27. package/packages/visualizer/src/admin/main.tsx +11 -0
  28. package/packages/visualizer/src/app/AppShell.tsx +62 -38
  29. package/packages/visualizer/src/app/gazetteer.tsx +90 -83
  30. package/packages/visualizer/src/app/router.tsx +33 -15
  31. package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
  32. package/packages/visualizer/src/components/DataIssues.tsx +36 -54
  33. package/packages/visualizer/src/components/Header.tsx +197 -125
  34. package/packages/visualizer/src/components/Legend.tsx +13 -41
  35. package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
  36. package/packages/visualizer/src/components/Screen.tsx +80 -71
  37. package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
  38. package/packages/visualizer/src/components/Status.tsx +20 -34
  39. package/packages/visualizer/src/components/labels.tsx +8 -3
  40. package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
  41. package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
  42. package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
  43. package/packages/visualizer/src/components/spec-logos/README.md +23 -0
  44. package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
  45. package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
  46. package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
  47. package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
  48. package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
  49. package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
  50. package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
  51. package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
  52. package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
  53. package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
  54. package/packages/visualizer/src/evidence/document-page.tsx +51 -29
  55. package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
  56. package/packages/visualizer/src/evidence/marks.tsx +112 -79
  57. package/packages/visualizer/src/evidence/model-page.tsx +77 -46
  58. package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
  59. package/packages/visualizer/src/evidence/overview.tsx +137 -69
  60. package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
  61. package/packages/visualizer/src/evidence/recording.tsx +135 -68
  62. package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
  63. package/packages/visualizer/src/evidence/source-text.tsx +33 -0
  64. package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
  65. package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
  66. package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
  67. package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
  68. package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
  69. package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
  70. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
  71. package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
  72. package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
  73. package/packages/visualizer/src/explorer/parts.tsx +35 -13
  74. package/packages/visualizer/src/find/Finder.tsx +106 -90
  75. package/packages/visualizer/src/find/open.ts +17 -20
  76. package/packages/visualizer/src/live/test-progress.tsx +60 -22
  77. package/packages/visualizer/src/looks/Stage.tsx +223 -110
  78. package/packages/visualizer/src/main.tsx +9 -11
  79. package/packages/visualizer/src/map/ChartMap.tsx +125 -69
  80. package/packages/visualizer/src/map/EventEdge.tsx +59 -37
  81. package/packages/visualizer/src/map/map.module.css +12 -546
  82. package/packages/visualizer/src/map/nodes.tsx +124 -70
  83. package/packages/visualizer/src/reachable.test.ts +3 -2
  84. package/packages/visualizer/src/spec-kind.ts +2 -2
  85. package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
  86. package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
  87. package/packages/visualizer/src/ui/grammars/README.md +14 -0
  88. package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
  89. package/packages/visualizer/src/ui/grammars/quint.json +152 -0
  90. package/packages/visualizer/src/ui/highlight.ts +50 -0
  91. package/packages/visualizer/src/ui/theme.tsx +109 -0
  92. package/packages/visualizer/src/views/ChartPage.tsx +46 -26
  93. package/packages/visualizer/src/views/ChartTests.tsx +59 -22
  94. package/packages/visualizer/src/views/ProductPage.tsx +16 -15
  95. package/packages/visualizer/src/views/SidePanel.tsx +241 -131
  96. package/packages/visualizer/src/app/gazetteer.module.css +0 -107
  97. package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
  98. package/packages/visualizer/src/components/DataIssues.module.css +0 -136
  99. package/packages/visualizer/src/components/Header.module.css +0 -114
  100. package/packages/visualizer/src/components/Legend.module.css +0 -84
  101. package/packages/visualizer/src/components/Screen.module.css +0 -312
  102. package/packages/visualizer/src/components/Status.module.css +0 -116
  103. package/packages/visualizer/src/components/icons.tsx +0 -156
  104. package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
  105. package/packages/visualizer/src/evidence/evidence.module.css +0 -907
  106. package/packages/visualizer/src/evidence/recording.module.css +0 -460
  107. package/packages/visualizer/src/evidence/sources.module.css +0 -168
  108. package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
  109. package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
  110. package/packages/visualizer/src/find/Finder.module.css +0 -168
  111. package/packages/visualizer/src/looks/stage.module.css +0 -300
  112. package/packages/visualizer/src/styles/global.css +0 -105
  113. package/packages/visualizer/src/styles/tokens.css +0 -80
  114. package/packages/visualizer/src/views/views.module.css +0 -546
@@ -68,6 +68,7 @@ Every endpoint below answers JSON, or Markdown with \`format=markdown\`. Prefix
68
68
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?format=markdown&limit=50" # summary, environments, domains, failing, flaky, slowest, then the tests
69
69
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?status=failed,flaky&environment=desktop-web" # filter: q, status, environment, producer, domain, spec, tag, run; sort, offset, limit
70
70
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/history?key=<url-encoded test key>" # one test: every recorded result, durations, branches, CI runs
71
+ curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/health?format=markdown" # why Specs, domains and counts disagree; add root=<from>><to> to declare a path-root mapping
71
72
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs?format=markdown" # CI runs, newest first, with their results by status
72
73
  curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs/detail?key=<url-encoded run key>" # every test one run recorded
73
74
  \`\`\`
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod/v4';
16
16
  import { ProjectsSchema } from '../../schema/src/project.js';
17
- import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, TestHistorySchema, RunListSchema, RunDetailSchema } from '../../schema/src/api.js';
17
+ import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, RunListSchema, RunDetailSchema } from '../../schema/src/api.js';
18
18
  import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
19
19
  /** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
20
20
  export const API_VERSION = '1.4.0';
@@ -35,7 +35,7 @@ const COMPONENTS = {
35
35
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
36
36
  StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
37
37
  Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
38
- TestCatalog: [TestCatalogSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
38
+ TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
39
39
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
40
40
  RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
41
41
  };
@@ -279,6 +279,22 @@ const BASE_OPERATIONS = [
279
279
  ],
280
280
  responses: { '200': { ...json('The catalog.', 'TestCatalog'), headers: { ETag: { description: 'Changes with the report, its history and the query.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
281
281
  },
282
+ {
283
+ route: 'GET /api/reports/tests/health', path: '/api/reports/tests/health', operationId: 'getCatalogHealth', tag: 'Report', role: 'read',
284
+ summary: 'Why specs, domains and counts disagree',
285
+ description: [
286
+ 'Diagnoses the report\'s metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.',
287
+ '',
288
+ '`counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.',
289
+ '',
290
+ 'Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check\'s label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.'
291
+ ].join('\n'),
292
+ parameters: [
293
+ { name: 'root', in: 'query', description: 'A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.', schema: str, example: '>specs/' },
294
+ { name: 'format', in: 'query', description: '`markdown` for a Markdown report instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
295
+ ],
296
+ responses: { '200': { ...json('The diagnostics.', 'CatalogHealth'), headers: { ETag: { description: 'Changes with the report, its history and the mappings.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
297
+ },
282
298
  {
283
299
  route: 'GET /api/reports/tests/history', path: '/api/reports/tests/history', operationId: 'getTestHistory', tag: 'Report', role: 'read',
284
300
  summary: 'One test and every result recorded for it',
@@ -1,8 +1,8 @@
1
- /** The settings form and its browser controller, also usable without a built client. */
2
- export declare const SETTINGS_PAGE = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Settings \u00B7 Atlas</title>\n<style>\n:root{--font:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;--mono:ui-monospace,monospace;--bg:#fff;--bg-2:#f7f9f9;--ink:#0f1419;--ink-3:#536471;--line:#eff3f4;--line-2:#cfd9de;--place:#0b63ce;--place-soft:#e8f1fd;--passed-ink:#116329;--passed-soft:#e6f4ea;--failed-ink:#a40e26;--failed-soft:#ffebe9;--focus:#0b63ce}\n/* atlas-client-tokens */\n*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased}a{color:var(--place);text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea,summary{outline-offset:3px}:focus-visible{outline:2px solid var(--focus)}[hidden]{display:none!important}\n.topbar{height:54px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--line);background:var(--bg)}.brand{font-size:15px;font-weight:700;color:var(--ink)}.crumb{display:flex;align-items:center;gap:16px;color:var(--ink-3)}.crumb span:first-child{color:var(--line-2)}.back{margin-left:auto;font-size:13px;color:var(--ink-3)}\n.layout{display:grid;grid-template-columns:180px minmax(0,760px);gap:48px;max-width:1080px;padding:40px 24px 64px;margin:auto}.side{position:sticky;top:24px;align-self:start}.side p{margin:0 0 12px 10px;color:var(--ink-3);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.side a{display:block;border-radius:6px;padding:8px 10px;color:var(--ink-3);font-size:13px}.side a:hover{background:var(--bg-2);color:var(--ink)}main{min-width:0}h1{margin:0 0 8px;font-size:26px;line-height:1.25;letter-spacing:-.025em}h2{font-size:16px;margin:0 0 4px}h3{font-size:13px;margin:0 0 8px}.lead{margin:0 0 24px;color:var(--ink-3);max-width:62ch}.section{padding:24px 0;border-top:1px solid var(--line);scroll-margin-top:24px}.description{margin:0 0 18px;color:var(--ink-3);font-size:13px}.access{border:1px solid var(--line-2);border-radius:8px;padding:20px;margin-bottom:24px}.access h2{margin-bottom:6px}.access .description{margin-bottom:14px}.access-row{display:flex;align-items:flex-end;gap:10px}.access-row .field{flex:1;margin:0}.field{margin:0 0 18px;min-width:0}.field:last-child{margin-bottom:0}label{display:block;font-weight:500;font-size:13px;margin-bottom:6px}input,select,textarea{display:block;min-height:36px;width:100%;min-width:0;padding:8px 10px;border:1px solid var(--line-2);border-radius:6px;color:var(--ink);background:var(--bg);line-height:1.4}input::placeholder{color:var(--ink-3);opacity:.8}input:disabled,select:disabled,textarea:disabled{background:var(--bg-2);color:var(--ink-3)}.hint,.current{margin:6px 0 0;color:var(--ink-3);font-size:12px;overflow-wrap:anywhere}.current:empty{display:none}.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}fieldset{border:0;padding:0;margin:0;min-width:0}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:7px 12px;border:1px solid var(--line-2);border-radius:6px;background:var(--bg);color:var(--ink);font-size:13px;font-weight:500;cursor:pointer;line-height:1.4;white-space:nowrap}button:hover:not(:disabled){background:var(--bg-2)}button:disabled{opacity:.5;cursor:default}.primary{background:var(--place);border-color:var(--place);color:#fff}.primary:hover:not(:disabled){background:var(--place);filter:brightness(.95)}.badge{font-size:11px;border-radius:4px;background:var(--passed-soft);color:var(--passed-ink);padding:3px 6px;margin-left:8px;font-weight:500}.notice{padding:12px 14px;border-radius:6px;background:var(--place-soft);font-size:13px;color:var(--ink-3);margin:0 0 20px}.notice strong{color:var(--ink)}#status:empty{display:none}#status{padding:12px 14px;border-radius:6px;font-size:13px;margin:0 0 20px;overflow-wrap:anywhere}#status[data-state=error]{background:var(--failed-soft);color:var(--failed-ink)}#status[data-state=success]{background:var(--passed-soft);color:var(--passed-ink)}#status[data-state=info]{background:var(--bg-2);color:var(--ink-3)}\n.backend{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0 0 12px}.backend-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.backend-head strong{font-size:13px}.remove{border:0;color:var(--ink-3);min-height:28px;padding:4px 8px}.empty{padding:12px 0;color:var(--ink-3);font-size:13px}.savebar{display:flex;align-items:center;gap:16px;padding:20px 0;border-top:1px solid var(--line);margin-top:20px}.savebar p{margin:0;color:var(--ink-3);font-size:12px}details{border:1px solid var(--line);border-radius:6px;margin:12px 0}summary{padding:12px 14px;cursor:pointer;font-size:13px;font-weight:500}details>div{padding:0 14px 14px}textarea#settings{min-height:240px;resize:vertical;font:12px/1.6 var(--mono);tab-size:2}pre{font:12px/1.6 var(--mono);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}code{font-family:var(--mono);font-size:12px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}\n#load[aria-busy=true]::before{content:\"\";width:10px;height:10px;border:1.5px solid currentColor;border-right-color:transparent;border-bottom-color:transparent;border-radius:50%;opacity:.65;animation:working 1.8s linear infinite}@keyframes working{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#load[aria-busy=true]::before{animation:none}}\n@media(max-width:760px){.topbar{padding:0 16px}.layout{display:block;padding:24px 16px 40px}.side{position:static;display:flex;flex-wrap:wrap;gap:4px;margin-bottom:24px}.side p{display:none}.side a{padding:6px 10px}.grid{grid-template-columns:1fr;gap:0}.access-row{align-items:stretch;flex-direction:column}.savebar{align-items:flex-start;flex-direction:column;gap:10px}h1{font-size:24px}}\n</style></head><body>\n<header class=\"topbar\"><a class=\"brand report-link\" href=\"/\">Atlas</a><div class=\"crumb\"><span aria-hidden=\"true\">\u203A</span><span>Settings</span></div><a class=\"back report-link\" href=\"/\">\u2190 Back to reports</a></header>\n<div class=\"layout\"><nav class=\"side\" aria-label=\"Settings sections\"><p>Server settings</p><a href=\"#access\">Access</a><a href=\"#storage\">Storage</a><a href=\"#live\">Live updates</a><a href=\"#rules\">Report rules</a><a href=\"#advanced\">Advanced</a></nav>\n<main><h1>Atlas server settings</h1><p class=\"lead\">Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.</p>\n<section class=\"access\" id=\"access\"><h2>Administrator access <span id=\"connected\" class=\"badge\" hidden>Connected</span></h2><p class=\"description\">Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.</p><div class=\"access-row\"><div class=\"field\"><label for=\"token\">Admin token</label><input id=\"token\" type=\"password\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"Enter your admin token\" aria-describedby=\"token-hint\"></div><button id=\"load\" type=\"button\">Load settings</button></div><p class=\"hint\" id=\"token-hint\">Your token stays in this page and is never saved in browser storage.</p></section>\n<p id=\"status\" role=\"status\" aria-live=\"polite\"></p>\n<div id=\"configuration\" hidden>\n<p class=\"notice\"><strong>Changes apply after a server restart.</strong> Environment variables and command-line flags can override saved settings. Current values are shown under each field.</p>\n<form id=\"form\"><fieldset id=\"fields\" disabled>\n<section class=\"section\" id=\"storage\"><h2>Storage</h2><p class=\"description\">Choose where reports are stored. Changing a location does not move existing reports.</p>\n<div class=\"field\"><label for=\"data\">Local data folder</label><input id=\"data\" type=\"text\" placeholder=\"./atlas-data\"><p class=\"hint\">The node's local storage. Relative paths start from the server configuration directory.</p><p class=\"current\" id=\"data-current\"></p></div>\n<div class=\"field\"><label for=\"write\">Upload destination</label><select id=\"write\"><option value=\"\">Use default destination</option><option value=\"data\">Local data folder</option></select><p class=\"hint\">New uploads go here. Reports are read from all configured storage locations.</p><p class=\"current\" id=\"write-current\"></p></div>\n<h3>Additional storage</h3><p class=\"description\">Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.</p><div id=\"backends\"></div><p class=\"empty\" id=\"no-backends\">No additional storage configured. Atlas uses its local data folder.</p><button id=\"add-backend\" type=\"button\">Add storage</button>\n</section>\n<section class=\"section\" id=\"live\"><h2>Live updates</h2><p class=\"description\">Control when silent or stalled test runs are marked interrupted.</p><div class=\"grid\">\n<div class=\"field\"><label for=\"runTimeoutSeconds\">Missing heartbeat timeout (seconds)</label><input id=\"runTimeoutSeconds\" type=\"number\" min=\"10\" max=\"3600\" step=\"any\"><p class=\"hint\">Interrupt a run when its producer stops checking in. Default: 30 seconds.</p><p class=\"current\" id=\"runTimeoutSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"runStallSeconds\">No-progress timeout (seconds)</label><input id=\"runStallSeconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"any\"><p class=\"hint\">Interrupt runs that stop making progress. Use 0 to disable. Default: 900 seconds.</p><p class=\"current\" id=\"runStallSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"refreshSeconds\">Storage refresh interval (seconds)</label><input id=\"refreshSeconds\" type=\"number\" min=\"1\" step=\"any\"><p class=\"hint\">Check for reports written outside this node. Default: 10 seconds.</p><p class=\"current\" id=\"refreshSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"heartbeatSeconds\">Client keepalive interval (seconds)</label><input id=\"heartbeatSeconds\" type=\"number\" min=\"1\" step=\"any\"><p class=\"hint\">Keep browser connections open between updates. Default: 15 seconds.</p><p class=\"current\" id=\"heartbeatSeconds-current\"></p></div>\n</div></section>\n<section class=\"section\" id=\"rules\"><h2>Report rules</h2><p class=\"description\">Choose which uploaded results contribute to the combined report.</p>\n<div class=\"field\"><label for=\"outlineBranch\">Preferred specification branch</label><input id=\"outlineBranch\" type=\"text\" placeholder=\"Automatic\"><p class=\"hint\">Leave blank to prefer main, then master, then the newest available specs.</p><p class=\"current\" id=\"outlineBranch-current\"></p></div>\n<div class=\"field\"><label for=\"branches\">Include branches</label><input id=\"branches\" type=\"text\" placeholder=\"All branches\"><p class=\"hint\">Comma-separated branch names. Leave blank to include every branch.</p><p class=\"current\" id=\"branches-current\"></p></div>\n<div class=\"field\"><label for=\"ignoreModified\">Results from modified checkouts</label><select id=\"ignoreModified\"><option value=\"\">Use default (include)</option><option value=\"false\">Include</option><option value=\"true\">Exclude</option></select><p class=\"current\" id=\"ignoreModified-current\"></p></div>\n</section>\n<section class=\"section\" id=\"advanced\"><h2>Advanced</h2><p class=\"description\">Request limits, logging, and the complete editable configuration.</p><div class=\"grid\">\n<div class=\"field\"><label for=\"maxObjectMiB\">Maximum upload size (MiB)</label><input id=\"maxObjectMiB\" type=\"number\" min=\"0.001\" step=\"any\"><p class=\"current\" id=\"maxObjectMiB-current\"></p></div>\n<div class=\"field\"><label for=\"maxJsonMiB\">Maximum JSON size (MiB)</label><input id=\"maxJsonMiB\" type=\"number\" min=\"0.001\" step=\"any\"><p class=\"current\" id=\"maxJsonMiB-current\"></p></div>\n<div class=\"field\"><label for=\"maxStreams\">Maximum live connections</label><input id=\"maxStreams\" type=\"number\" min=\"1\" step=\"1\"><p class=\"current\" id=\"maxStreams-current\"></p></div>\n<div class=\"field\"><label for=\"log\">Request logging</label><select id=\"log\"><option value=\"\">Use default (enabled)</option><option value=\"true\">Enabled</option><option value=\"false\">Disabled</option></select><p class=\"current\" id=\"log-current\"></p></div>\n</div><details id=\"json-editor\"><summary>Edit configuration as JSON</summary><div><p class=\"description\">This is the same saved configuration as the form above. Omitted values use defaults after restart.</p><label for=\"settings\" class=\"visually-hidden\">Storage and report settings (JSON)</label><textarea id=\"settings\" spellcheck=\"false\" disabled></textarea></div></details>\n<details><summary>View currently active settings</summary><div><p class=\"description\">The configuration running now, including environment and command-line overrides.</p><pre id=\"active\"></pre></div></details>\n</section></fieldset><footer class=\"savebar\"><button id=\"save\" class=\"primary\" type=\"button\" disabled>Save changes</button><p>Saved to <code>atlas.server.json</code>. Restart Atlas to apply.</p></footer></form></div>\n</main></div>\n<script type=\"module\">\nconst el=id=>document.getElementById(id);\nconst fields=[['data',['data']],['outlineBranch',['outlineBranch']],['runTimeoutSeconds',['runTimeoutSeconds']],['runStallSeconds',['runStallSeconds']],['refreshSeconds',['refreshSeconds']],['heartbeatSeconds',['heartbeatSeconds']],['branches',['include','branches']],['ignoreModified',['include','ignoreModified']],['log',['log']],['maxObjectMiB',['limits','maxObjectMiB']],['maxJsonMiB',['limits','maxJsonMiB']],['maxStreams',['limits','maxStreams']]];\nlet revision, draft={}, active={}, stale=false, busy=false;\nconst at=(object,path)=>path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);\nconst copy=value=>JSON.parse(JSON.stringify(value));\nfunction set(object,path,value){let held=object;for(const key of path.slice(0,-1)){if(!held[key]||typeof held[key]!=='object')held[key]={};held=held[key];}if(value===undefined)delete held[path.at(-1)];else held[path.at(-1)]=value;}\nfunction message(text,state='info'){el('status').textContent=text;el('status').dataset.state=state;el('status').setAttribute('role',state==='error'?'alert':'status');}\nfunction option(text,value){const node=document.createElement('option');node.textContent=text;node.value=value;return node;}\nfunction destinations(){const select=el('write'), value=select.value;select.replaceChildren(option('Use default destination',''),option('Local data folder','data'));for(const row of el('backends').children){const id=row.querySelector('[data-key=id]').value.trim();if(id){const label=row.querySelector('[data-key=label]').value.trim();select.add(option(label?label+' ('+id+')':id,id));}}if([...select.options].some(option=>option.value===value))select.value=value;}\nfunction addBackend(backend={}){const row=document.createElement('div');row.className='backend';if(backend.write!==undefined)row.dataset.write=String(backend.write);const head=document.createElement('div');head.className='backend-head';const title=document.createElement('strong');title.textContent='Storage location';const remove=document.createElement('button');remove.type='button';remove.className='remove';remove.textContent='Remove';remove.onclick=()=>{row.remove();destinations();el('no-backends').hidden=el('backends').children.length>0;sync();};head.append(title,remove);row.append(head);const group=document.createElement('div');group.className='grid';for(const [key,label,placeholder] of [['id','Storage ID','archive'],['label','Display name (optional)','Archive'],['url','Location','s3://reports/atlas']]){const field=document.createElement('div');field.className='field';const input=document.createElement('input');input.type='text';input.dataset.key=key;input.value=backend[key]??'';input.placeholder=placeholder;input.id='backend-'+key+'-'+Math.random().toString(36).slice(2);input.required=key!=='label';if(key==='id')input.pattern='[a-z0-9-]+';const caption=document.createElement('label');caption.htmlFor=input.id;caption.textContent=label;field.append(caption,input);if(key==='url')row.append(field);else group.append(field);}row.insertBefore(group,row.children[1]??null);el('backends').append(row);el('no-backends').hidden=true;destinations();}\nfunction fill(settings){draft=copy(settings);for(const [id,path]of fields){const value=at(settings,path);el(id).value=Array.isArray(value)?value.join(', '):value===undefined?'':String(value);}el('backends').replaceChildren();for(const backend of settings.backends??[])addBackend(backend);el('no-backends').hidden=el('backends').children.length>0;destinations();if(settings.write&&!Array.from(el('write').options).some(option=>option.value===settings.write))el('write').add(option(settings.write,settings.write));el('write').value=settings.write??'';}\nfunction formSettings(){const value=copy(draft);for(const [id,path]of fields){const input=el(id),text=input.value.trim();set(value,path,text===''?undefined:id==='branches'?text.split(',').map(item=>item.trim()).filter(Boolean):input.type==='number'?Number(text):id==='log'||id==='ignoreModified'?text==='true':text);}for(const key of ['include','limits'])if(value[key]&&Object.keys(value[key]).length===0)delete value[key];const backends=[...el('backends').children].map(row=>{const backend={};for(const input of row.querySelectorAll('[data-key]')){const text=input.value.trim();if(text||input.dataset.key!=='label')backend[input.dataset.key]=text;}if(row.dataset.write!==undefined)backend.write=row.dataset.write==='true';return backend;});if(backends.length||draft.backends!==undefined)value.backends=backends;else delete value.backends;if(el('write').value)value.write=el('write').value;else delete value.write;return value;}\nfunction sync(){el('settings').value=JSON.stringify(formSettings(),null,2);}\nfunction currentValues(){for(const [id,path]of fields){const value=at(active,path);el(id+'-current').textContent=value===undefined?'':'Current: '+(Array.isArray(value)?value.join(', ')||'All branches':String(value));if(el(id).tagName==='INPUT'&&value!==undefined&&el(id).value==='')el(id).placeholder=String(value);}el('write-current').textContent='Current: '+(active.write??'data');}\nfunction controls(){el('load').setAttribute('aria-busy',String(busy));el('load').disabled=busy;el('load').textContent=busy?'Working\u2026':revision?'Reload settings':'Load settings';el('save').disabled=busy||!revision||stale;el('fields').disabled=busy||!revision;el('settings').disabled=busy||!revision;}\nasync function request(method){const token=el('token').value;const headers=token?{authorization:'Bearer '+token}:{};let body;if(method==='PUT'){const parsed=JSON.parse(el('settings').value);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Settings must be a JSON object.');headers['content-type']='application/json';headers['if-match']='\"'+revision+'\"';body=JSON.stringify(parsed);}const response=await fetch('/api/settings',{method,headers,body,cache:'no-store'});const data=await response.json();if(!response.ok){if(response.status===409||response.status===428)stale=true;throw Error(data.detail||data.error||'Request failed ('+response.status+')');}revision=data.revision;stale=false;active=data.active;fill(data.settings);el('settings').value=JSON.stringify(data.settings,null,2);el('active').textContent=JSON.stringify(data.active,null,2);currentValues();el('configuration').hidden=false;el('connected').hidden=false;message(method==='PUT'||data.restartRequired?'Saved. Restart Atlas to apply.':'Settings loaded. Edit saved values below; blank fields use defaults after restart.',method==='PUT'||data.restartRequired?'success':'info');}\nfor(const [id,method]of [['load','GET'],['save','PUT']])el(id).onclick=async()=>{if(busy||(method==='PUT'&&(!revision||stale)))return;if(method==='PUT'&&!el('json-editor').open&&!el('form').reportValidity()){message('Check the highlighted fields before saving.','error');return;}if(method==='GET'){revision=undefined;el('connected').hidden=true;}busy=true;controls();try{await request(method);}catch(error){message(error instanceof SyntaxError?'The JSON configuration is invalid. Fix it before saving.':error.message,'error');}finally{busy=false;controls();}};\nel('form').onsubmit=event=>{event.preventDefault();if(!el('save').disabled)el('save').click();};\nel('fields').addEventListener('input',event=>{if(event.target===el('settings'))return;if(event.target.dataset.key==='id'||event.target.dataset.key==='label')destinations();sync();});\nel('fields').addEventListener('change',event=>{if(event.target===el('settings'))return;if(event.target===el('write'))for(const row of el('backends').children)delete row.dataset.write;sync();});\nel('settings').addEventListener('input',()=>{try{const settings=JSON.parse(el('settings').value);if(settings&&typeof settings==='object'&&!Array.isArray(settings))fill(settings);}catch{}});\nel('add-backend').onclick=()=>{addBackend();sync();el('backends').lastElementChild.querySelector('input').focus();};\nconst project=new URL(location.href).searchParams.get('project');if(project&&/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project)&&project.length<=64)for(const link of document.querySelectorAll('.report-link'))link.href='/?project='+encodeURIComponent(project)+'#/';\n</script></body></html>";
1
+ /** Document shell for the shared React settings UI. No configuration or credentials are embedded. */
2
+ export declare const SETTINGS_PAGE = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Settings \u00B7 Atlas</title><!-- atlas-admin-styles --></head><body><div id=\"root\"><h1>Atlas server settings</h1><p>Loading settings interface\u2026</p></div><!-- atlas-admin-script --></body></html>";
3
3
  /**
4
- * Render Settings using the exact design tokens shipped with the Atlas client. Loads and caches the stylesheet
5
- * on first request, never at import time. Minimal neutral tokens keep embedded API-only servers usable too.
4
+ * Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
5
+ * without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
6
6
  * @example
7
7
  * const html = await renderSettingsPage();
8
8
  */
@@ -1,93 +1,32 @@
1
1
  /** @module Settings page
2
- * A standalone administrator form, styled with the Atlas client's shared design tokens.
2
+ * The administrator's React and Mantine entry point, bundled lazily for API-only servers too.
3
3
  * Credentials remain in page memory; the settings API owns authorization and validation.
4
4
  */
5
- import { readFile } from 'node:fs/promises';
6
5
  import path from 'node:path';
7
6
  import { visualizerRoot } from './site.js';
8
- /** The settings form and its browser controller, also usable without a built client. */
9
- export const SETTINGS_PAGE = `<!doctype html>
10
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Settings · Atlas</title>
11
- <style>
12
- :root{--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,monospace;--bg:#fff;--bg-2:#f7f9f9;--ink:#0f1419;--ink-3:#536471;--line:#eff3f4;--line-2:#cfd9de;--place:#0b63ce;--place-soft:#e8f1fd;--passed-ink:#116329;--passed-soft:#e6f4ea;--failed-ink:#a40e26;--failed-soft:#ffebe9;--focus:#0b63ce}
13
- /* atlas-client-tokens */
14
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased}a{color:var(--place);text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea,summary{outline-offset:3px}:focus-visible{outline:2px solid var(--focus)}[hidden]{display:none!important}
15
- .topbar{height:54px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--line);background:var(--bg)}.brand{font-size:15px;font-weight:700;color:var(--ink)}.crumb{display:flex;align-items:center;gap:16px;color:var(--ink-3)}.crumb span:first-child{color:var(--line-2)}.back{margin-left:auto;font-size:13px;color:var(--ink-3)}
16
- .layout{display:grid;grid-template-columns:180px minmax(0,760px);gap:48px;max-width:1080px;padding:40px 24px 64px;margin:auto}.side{position:sticky;top:24px;align-self:start}.side p{margin:0 0 12px 10px;color:var(--ink-3);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.side a{display:block;border-radius:6px;padding:8px 10px;color:var(--ink-3);font-size:13px}.side a:hover{background:var(--bg-2);color:var(--ink)}main{min-width:0}h1{margin:0 0 8px;font-size:26px;line-height:1.25;letter-spacing:-.025em}h2{font-size:16px;margin:0 0 4px}h3{font-size:13px;margin:0 0 8px}.lead{margin:0 0 24px;color:var(--ink-3);max-width:62ch}.section{padding:24px 0;border-top:1px solid var(--line);scroll-margin-top:24px}.description{margin:0 0 18px;color:var(--ink-3);font-size:13px}.access{border:1px solid var(--line-2);border-radius:8px;padding:20px;margin-bottom:24px}.access h2{margin-bottom:6px}.access .description{margin-bottom:14px}.access-row{display:flex;align-items:flex-end;gap:10px}.access-row .field{flex:1;margin:0}.field{margin:0 0 18px;min-width:0}.field:last-child{margin-bottom:0}label{display:block;font-weight:500;font-size:13px;margin-bottom:6px}input,select,textarea{display:block;min-height:36px;width:100%;min-width:0;padding:8px 10px;border:1px solid var(--line-2);border-radius:6px;color:var(--ink);background:var(--bg);line-height:1.4}input::placeholder{color:var(--ink-3);opacity:.8}input:disabled,select:disabled,textarea:disabled{background:var(--bg-2);color:var(--ink-3)}.hint,.current{margin:6px 0 0;color:var(--ink-3);font-size:12px;overflow-wrap:anywhere}.current:empty{display:none}.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}fieldset{border:0;padding:0;margin:0;min-width:0}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:7px 12px;border:1px solid var(--line-2);border-radius:6px;background:var(--bg);color:var(--ink);font-size:13px;font-weight:500;cursor:pointer;line-height:1.4;white-space:nowrap}button:hover:not(:disabled){background:var(--bg-2)}button:disabled{opacity:.5;cursor:default}.primary{background:var(--place);border-color:var(--place);color:#fff}.primary:hover:not(:disabled){background:var(--place);filter:brightness(.95)}.badge{font-size:11px;border-radius:4px;background:var(--passed-soft);color:var(--passed-ink);padding:3px 6px;margin-left:8px;font-weight:500}.notice{padding:12px 14px;border-radius:6px;background:var(--place-soft);font-size:13px;color:var(--ink-3);margin:0 0 20px}.notice strong{color:var(--ink)}#status:empty{display:none}#status{padding:12px 14px;border-radius:6px;font-size:13px;margin:0 0 20px;overflow-wrap:anywhere}#status[data-state=error]{background:var(--failed-soft);color:var(--failed-ink)}#status[data-state=success]{background:var(--passed-soft);color:var(--passed-ink)}#status[data-state=info]{background:var(--bg-2);color:var(--ink-3)}
17
- .backend{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0 0 12px}.backend-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.backend-head strong{font-size:13px}.remove{border:0;color:var(--ink-3);min-height:28px;padding:4px 8px}.empty{padding:12px 0;color:var(--ink-3);font-size:13px}.savebar{display:flex;align-items:center;gap:16px;padding:20px 0;border-top:1px solid var(--line);margin-top:20px}.savebar p{margin:0;color:var(--ink-3);font-size:12px}details{border:1px solid var(--line);border-radius:6px;margin:12px 0}summary{padding:12px 14px;cursor:pointer;font-size:13px;font-weight:500}details>div{padding:0 14px 14px}textarea#settings{min-height:240px;resize:vertical;font:12px/1.6 var(--mono);tab-size:2}pre{font:12px/1.6 var(--mono);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}code{font-family:var(--mono);font-size:12px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}
18
- #load[aria-busy=true]::before{content:"";width:10px;height:10px;border:1.5px solid currentColor;border-right-color:transparent;border-bottom-color:transparent;border-radius:50%;opacity:.65;animation:working 1.8s linear infinite}@keyframes working{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#load[aria-busy=true]::before{animation:none}}
19
- @media(max-width:760px){.topbar{padding:0 16px}.layout{display:block;padding:24px 16px 40px}.side{position:static;display:flex;flex-wrap:wrap;gap:4px;margin-bottom:24px}.side p{display:none}.side a{padding:6px 10px}.grid{grid-template-columns:1fr;gap:0}.access-row{align-items:stretch;flex-direction:column}.savebar{align-items:flex-start;flex-direction:column;gap:10px}h1{font-size:24px}}
20
- </style></head><body>
21
- <header class="topbar"><a class="brand report-link" href="/">Atlas</a><div class="crumb"><span aria-hidden="true">›</span><span>Settings</span></div><a class="back report-link" href="/">← Back to reports</a></header>
22
- <div class="layout"><nav class="side" aria-label="Settings sections"><p>Server settings</p><a href="#access">Access</a><a href="#storage">Storage</a><a href="#live">Live updates</a><a href="#rules">Report rules</a><a href="#advanced">Advanced</a></nav>
23
- <main><h1>Atlas server settings</h1><p class="lead">Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.</p>
24
- <section class="access" id="access"><h2>Administrator access <span id="connected" class="badge" hidden>Connected</span></h2><p class="description">Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.</p><div class="access-row"><div class="field"><label for="token">Admin token</label><input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="Enter your admin token" aria-describedby="token-hint"></div><button id="load" type="button">Load settings</button></div><p class="hint" id="token-hint">Your token stays in this page and is never saved in browser storage.</p></section>
25
- <p id="status" role="status" aria-live="polite"></p>
26
- <div id="configuration" hidden>
27
- <p class="notice"><strong>Changes apply after a server restart.</strong> Environment variables and command-line flags can override saved settings. Current values are shown under each field.</p>
28
- <form id="form"><fieldset id="fields" disabled>
29
- <section class="section" id="storage"><h2>Storage</h2><p class="description">Choose where reports are stored. Changing a location does not move existing reports.</p>
30
- <div class="field"><label for="data">Local data folder</label><input id="data" type="text" placeholder="./atlas-data"><p class="hint">The node's local storage. Relative paths start from the server configuration directory.</p><p class="current" id="data-current"></p></div>
31
- <div class="field"><label for="write">Upload destination</label><select id="write"><option value="">Use default destination</option><option value="data">Local data folder</option></select><p class="hint">New uploads go here. Reports are read from all configured storage locations.</p><p class="current" id="write-current"></p></div>
32
- <h3>Additional storage</h3><p class="description">Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.</p><div id="backends"></div><p class="empty" id="no-backends">No additional storage configured. Atlas uses its local data folder.</p><button id="add-backend" type="button">Add storage</button>
33
- </section>
34
- <section class="section" id="live"><h2>Live updates</h2><p class="description">Control when silent or stalled test runs are marked interrupted.</p><div class="grid">
35
- <div class="field"><label for="runTimeoutSeconds">Missing heartbeat timeout (seconds)</label><input id="runTimeoutSeconds" type="number" min="10" max="3600" step="any"><p class="hint">Interrupt a run when its producer stops checking in. Default: 30 seconds.</p><p class="current" id="runTimeoutSeconds-current"></p></div>
36
- <div class="field"><label for="runStallSeconds">No-progress timeout (seconds)</label><input id="runStallSeconds" type="number" min="0" max="86400" step="any"><p class="hint">Interrupt runs that stop making progress. Use 0 to disable. Default: 900 seconds.</p><p class="current" id="runStallSeconds-current"></p></div>
37
- <div class="field"><label for="refreshSeconds">Storage refresh interval (seconds)</label><input id="refreshSeconds" type="number" min="1" step="any"><p class="hint">Check for reports written outside this node. Default: 10 seconds.</p><p class="current" id="refreshSeconds-current"></p></div>
38
- <div class="field"><label for="heartbeatSeconds">Client keepalive interval (seconds)</label><input id="heartbeatSeconds" type="number" min="1" step="any"><p class="hint">Keep browser connections open between updates. Default: 15 seconds.</p><p class="current" id="heartbeatSeconds-current"></p></div>
39
- </div></section>
40
- <section class="section" id="rules"><h2>Report rules</h2><p class="description">Choose which uploaded results contribute to the combined report.</p>
41
- <div class="field"><label for="outlineBranch">Preferred specification branch</label><input id="outlineBranch" type="text" placeholder="Automatic"><p class="hint">Leave blank to prefer main, then master, then the newest available specs.</p><p class="current" id="outlineBranch-current"></p></div>
42
- <div class="field"><label for="branches">Include branches</label><input id="branches" type="text" placeholder="All branches"><p class="hint">Comma-separated branch names. Leave blank to include every branch.</p><p class="current" id="branches-current"></p></div>
43
- <div class="field"><label for="ignoreModified">Results from modified checkouts</label><select id="ignoreModified"><option value="">Use default (include)</option><option value="false">Include</option><option value="true">Exclude</option></select><p class="current" id="ignoreModified-current"></p></div>
44
- </section>
45
- <section class="section" id="advanced"><h2>Advanced</h2><p class="description">Request limits, logging, and the complete editable configuration.</p><div class="grid">
46
- <div class="field"><label for="maxObjectMiB">Maximum upload size (MiB)</label><input id="maxObjectMiB" type="number" min="0.001" step="any"><p class="current" id="maxObjectMiB-current"></p></div>
47
- <div class="field"><label for="maxJsonMiB">Maximum JSON size (MiB)</label><input id="maxJsonMiB" type="number" min="0.001" step="any"><p class="current" id="maxJsonMiB-current"></p></div>
48
- <div class="field"><label for="maxStreams">Maximum live connections</label><input id="maxStreams" type="number" min="1" step="1"><p class="current" id="maxStreams-current"></p></div>
49
- <div class="field"><label for="log">Request logging</label><select id="log"><option value="">Use default (enabled)</option><option value="true">Enabled</option><option value="false">Disabled</option></select><p class="current" id="log-current"></p></div>
50
- </div><details id="json-editor"><summary>Edit configuration as JSON</summary><div><p class="description">This is the same saved configuration as the form above. Omitted values use defaults after restart.</p><label for="settings" class="visually-hidden">Storage and report settings (JSON)</label><textarea id="settings" spellcheck="false" disabled></textarea></div></details>
51
- <details><summary>View currently active settings</summary><div><p class="description">The configuration running now, including environment and command-line overrides.</p><pre id="active"></pre></div></details>
52
- </section></fieldset><footer class="savebar"><button id="save" class="primary" type="button" disabled>Save changes</button><p>Saved to <code>atlas.server.json</code>. Restart Atlas to apply.</p></footer></form></div>
53
- </main></div>
54
- <script type="module">
55
- const el=id=>document.getElementById(id);
56
- const fields=[['data',['data']],['outlineBranch',['outlineBranch']],['runTimeoutSeconds',['runTimeoutSeconds']],['runStallSeconds',['runStallSeconds']],['refreshSeconds',['refreshSeconds']],['heartbeatSeconds',['heartbeatSeconds']],['branches',['include','branches']],['ignoreModified',['include','ignoreModified']],['log',['log']],['maxObjectMiB',['limits','maxObjectMiB']],['maxJsonMiB',['limits','maxJsonMiB']],['maxStreams',['limits','maxStreams']]];
57
- let revision, draft={}, active={}, stale=false, busy=false;
58
- const at=(object,path)=>path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);
59
- const copy=value=>JSON.parse(JSON.stringify(value));
60
- function set(object,path,value){let held=object;for(const key of path.slice(0,-1)){if(!held[key]||typeof held[key]!=='object')held[key]={};held=held[key];}if(value===undefined)delete held[path.at(-1)];else held[path.at(-1)]=value;}
61
- function message(text,state='info'){el('status').textContent=text;el('status').dataset.state=state;el('status').setAttribute('role',state==='error'?'alert':'status');}
62
- function option(text,value){const node=document.createElement('option');node.textContent=text;node.value=value;return node;}
63
- function destinations(){const select=el('write'), value=select.value;select.replaceChildren(option('Use default destination',''),option('Local data folder','data'));for(const row of el('backends').children){const id=row.querySelector('[data-key=id]').value.trim();if(id){const label=row.querySelector('[data-key=label]').value.trim();select.add(option(label?label+' ('+id+')':id,id));}}if([...select.options].some(option=>option.value===value))select.value=value;}
64
- function addBackend(backend={}){const row=document.createElement('div');row.className='backend';if(backend.write!==undefined)row.dataset.write=String(backend.write);const head=document.createElement('div');head.className='backend-head';const title=document.createElement('strong');title.textContent='Storage location';const remove=document.createElement('button');remove.type='button';remove.className='remove';remove.textContent='Remove';remove.onclick=()=>{row.remove();destinations();el('no-backends').hidden=el('backends').children.length>0;sync();};head.append(title,remove);row.append(head);const group=document.createElement('div');group.className='grid';for(const [key,label,placeholder] of [['id','Storage ID','archive'],['label','Display name (optional)','Archive'],['url','Location','s3://reports/atlas']]){const field=document.createElement('div');field.className='field';const input=document.createElement('input');input.type='text';input.dataset.key=key;input.value=backend[key]??'';input.placeholder=placeholder;input.id='backend-'+key+'-'+Math.random().toString(36).slice(2);input.required=key!=='label';if(key==='id')input.pattern='[a-z0-9-]+';const caption=document.createElement('label');caption.htmlFor=input.id;caption.textContent=label;field.append(caption,input);if(key==='url')row.append(field);else group.append(field);}row.insertBefore(group,row.children[1]??null);el('backends').append(row);el('no-backends').hidden=true;destinations();}
65
- function fill(settings){draft=copy(settings);for(const [id,path]of fields){const value=at(settings,path);el(id).value=Array.isArray(value)?value.join(', '):value===undefined?'':String(value);}el('backends').replaceChildren();for(const backend of settings.backends??[])addBackend(backend);el('no-backends').hidden=el('backends').children.length>0;destinations();if(settings.write&&!Array.from(el('write').options).some(option=>option.value===settings.write))el('write').add(option(settings.write,settings.write));el('write').value=settings.write??'';}
66
- function formSettings(){const value=copy(draft);for(const [id,path]of fields){const input=el(id),text=input.value.trim();set(value,path,text===''?undefined:id==='branches'?text.split(',').map(item=>item.trim()).filter(Boolean):input.type==='number'?Number(text):id==='log'||id==='ignoreModified'?text==='true':text);}for(const key of ['include','limits'])if(value[key]&&Object.keys(value[key]).length===0)delete value[key];const backends=[...el('backends').children].map(row=>{const backend={};for(const input of row.querySelectorAll('[data-key]')){const text=input.value.trim();if(text||input.dataset.key!=='label')backend[input.dataset.key]=text;}if(row.dataset.write!==undefined)backend.write=row.dataset.write==='true';return backend;});if(backends.length||draft.backends!==undefined)value.backends=backends;else delete value.backends;if(el('write').value)value.write=el('write').value;else delete value.write;return value;}
67
- function sync(){el('settings').value=JSON.stringify(formSettings(),null,2);}
68
- function currentValues(){for(const [id,path]of fields){const value=at(active,path);el(id+'-current').textContent=value===undefined?'':'Current: '+(Array.isArray(value)?value.join(', ')||'All branches':String(value));if(el(id).tagName==='INPUT'&&value!==undefined&&el(id).value==='')el(id).placeholder=String(value);}el('write-current').textContent='Current: '+(active.write??'data');}
69
- function controls(){el('load').setAttribute('aria-busy',String(busy));el('load').disabled=busy;el('load').textContent=busy?'Working…':revision?'Reload settings':'Load settings';el('save').disabled=busy||!revision||stale;el('fields').disabled=busy||!revision;el('settings').disabled=busy||!revision;}
70
- async function request(method){const token=el('token').value;const headers=token?{authorization:'Bearer '+token}:{};let body;if(method==='PUT'){const parsed=JSON.parse(el('settings').value);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Settings must be a JSON object.');headers['content-type']='application/json';headers['if-match']='"'+revision+'"';body=JSON.stringify(parsed);}const response=await fetch('/api/settings',{method,headers,body,cache:'no-store'});const data=await response.json();if(!response.ok){if(response.status===409||response.status===428)stale=true;throw Error(data.detail||data.error||'Request failed ('+response.status+')');}revision=data.revision;stale=false;active=data.active;fill(data.settings);el('settings').value=JSON.stringify(data.settings,null,2);el('active').textContent=JSON.stringify(data.active,null,2);currentValues();el('configuration').hidden=false;el('connected').hidden=false;message(method==='PUT'||data.restartRequired?'Saved. Restart Atlas to apply.':'Settings loaded. Edit saved values below; blank fields use defaults after restart.',method==='PUT'||data.restartRequired?'success':'info');}
71
- for(const [id,method]of [['load','GET'],['save','PUT']])el(id).onclick=async()=>{if(busy||(method==='PUT'&&(!revision||stale)))return;if(method==='PUT'&&!el('json-editor').open&&!el('form').reportValidity()){message('Check the highlighted fields before saving.','error');return;}if(method==='GET'){revision=undefined;el('connected').hidden=true;}busy=true;controls();try{await request(method);}catch(error){message(error instanceof SyntaxError?'The JSON configuration is invalid. Fix it before saving.':error.message,'error');}finally{busy=false;controls();}};
72
- el('form').onsubmit=event=>{event.preventDefault();if(!el('save').disabled)el('save').click();};
73
- el('fields').addEventListener('input',event=>{if(event.target===el('settings'))return;if(event.target.dataset.key==='id'||event.target.dataset.key==='label')destinations();sync();});
74
- el('fields').addEventListener('change',event=>{if(event.target===el('settings'))return;if(event.target===el('write'))for(const row of el('backends').children)delete row.dataset.write;sync();});
75
- el('settings').addEventListener('input',()=>{try{const settings=JSON.parse(el('settings').value);if(settings&&typeof settings==='object'&&!Array.isArray(settings))fill(settings);}catch{}});
76
- el('add-backend').onclick=()=>{addBackend();sync();el('backends').lastElementChild.querySelector('input').focus();};
77
- const project=new URL(location.href).searchParams.get('project');if(project&&/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project)&&project.length<=64)for(const link of document.querySelectorAll('.report-link'))link.href='/?project='+encodeURIComponent(project)+'#/';
78
- </script></body></html>`;
7
+ /** Document shell for the shared React settings UI. No configuration or credentials are embedded. */
8
+ export const SETTINGS_PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Settings · Atlas</title><!-- atlas-admin-styles --></head><body><div id="root"><h1>Atlas server settings</h1><p>Loading settings interface…</p></div><!-- atlas-admin-script --></body></html>`;
79
9
  let page;
80
10
  /**
81
- * Render Settings using the exact design tokens shipped with the Atlas client. Loads and caches the stylesheet
82
- * on first request, never at import time. Minimal neutral tokens keep embedded API-only servers usable too.
11
+ * Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
12
+ * without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
83
13
  * @example
84
14
  * const html = await renderSettingsPage();
85
15
  */
86
16
  export function renderSettingsPage() {
87
17
  page ??= (async () => {
88
18
  const root = visualizerRoot();
89
- const tokens = root ? await readFile(path.join(root, 'src/styles/tokens.css'), 'utf8').catch(() => '') : '';
90
- return SETTINGS_PAGE.replace('/* atlas-client-tokens */', tokens);
91
- })();
19
+ if (!root)
20
+ throw new Error('The Atlas client is not installed alongside the server.');
21
+ const result = await Bun.build({ entrypoints: [path.join(root, 'src/admin/main.tsx')], target: 'browser', minify: true });
22
+ if (!result.success)
23
+ throw new Error(`The settings interface could not be built: ${result.logs.join('\n')}`);
24
+ const js = await Promise.all(result.outputs.filter((output) => output.path.endsWith('.js')).map((output) => output.text()));
25
+ const css = await Promise.all(result.outputs.filter((output) => output.path.endsWith('.css')).map((output) => output.text()));
26
+ return SETTINGS_PAGE.replace('<!-- atlas-admin-styles -->', () => `<style>${css.join('\n').replaceAll('</style', '<\\/style')}</style>`).replace('<!-- atlas-admin-script -->', () => `<script type="module">${js.join('\n').replaceAll('</script', '<\\/script')}</script>`);
27
+ })().catch((error) => {
28
+ page = undefined;
29
+ throw error;
30
+ });
92
31
  return page;
93
32
  }
@@ -504,6 +504,40 @@ Scoped to one project. The same project may be selected on the unprefixed API us
504
504
 
505
505
  Responses: `200` The catalog.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
506
506
 
507
+ ### Why specs, domains and counts disagree
508
+
509
+ `GET /api/reports/tests/health` · role: read
510
+
511
+ Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.
512
+
513
+ `counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
514
+
515
+ Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
516
+
517
+ - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
518
+ - `format` (query): `markdown` for a Markdown report instead of JSON.
519
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
520
+
521
+ Responses: `200` The diagnostics.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
522
+
523
+ ### Why specs, domains and counts disagree
524
+
525
+ `GET /api/projects/{project}/reports/tests/health` · role: read
526
+
527
+ Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.
528
+
529
+ `counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
530
+
531
+ Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
532
+
533
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
534
+
535
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
536
+ - `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
537
+ - `format` (query): `markdown` for a Markdown report instead of JSON.
538
+
539
+ Responses: `200` The diagnostics.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
540
+
507
541
  ### One test and every result recorded for it
508
542
 
509
543
  `GET /api/reports/tests/history` · role: read
@@ -330,6 +330,227 @@ export async function readBundleIndex(
330
330
 
331
331
  Reads and parses the bundle index at `key`; an index that is not there is an empty bundle (`{}`). Invalid JSON throws.
332
332
 
333
+ ### Catalog health: spec identity, domains and denominators
334
+
335
+ Source: `packages/reports/src/catalog-health.ts`. Import: `@crvouga/atlas/reports/catalog-health`
336
+
337
+ Explains why the Specs and the Tests of a report may disagree, and what a producer or an operator can change about it.
338
+ `catalogHealth` reads a `CatalogModel` (the tests of a report and the specs it is laid over) and writes a
339
+ `CatalogHealth` document:
340
+
341
+ - **Spec identity.** The same promise can reach Atlas under two paths (`checkout/cart.feature` published,
342
+ `specs/checkout/cart.feature` recorded). Atlas joins two paths only when a declared root mapping (`RootMapping`)
343
+ relates them, and then shows one logical spec with its original paths. Without a mapping it never merges by a
344
+ similar title or file name: it reports the pair as ambiguous and names the candidates.
345
+ - **Domains.** A domain is whatever the spec's metadata says. When almost every spec shares one value, often the root
346
+ folder, the document says so; it never replaces explicit metadata.
347
+ - **Denominators.** Defined specs, specs with an executed test, tests (check identities), examples and per-environment
348
+ observations are different numbers. `reconciliation` walks from the number the Specs view shows to the number the
349
+ Tests view groups by, step by step, and `counts.scenarios` tells one outline, its examples and their observations apart.
350
+ - **Fallback identity.** A check whose id is its title has no stable id; renaming it starts a new check. Counted, not
351
+ guessed at.
352
+
353
+ It only reads: it never changes a status, a slot or a winner, so it cannot disagree with the report it describes.
354
+ Optional label `outline` on a check names the scenario outline its example belongs to.
355
+
356
+ See docs/server.md#reading-the-report-as-an-agent
357
+
358
+ #### `CATALOG_HEALTH_VERSION`
359
+
360
+ ```ts
361
+ export const CATALOG_HEALTH_VERSION = …
362
+ ```
363
+
364
+ The version of the `CatalogHealth` document. Only optional fields are added within it.
365
+
366
+ #### `HEALTH_ITEM_LIMIT`
367
+
368
+ ```ts
369
+ export const HEALTH_ITEM_LIMIT = …
370
+ ```
371
+
372
+ The most paths listed in one finding; its `count` is always the whole.
373
+
374
+ #### `RootMapping`
375
+
376
+ ```ts
377
+ export type RootMapping = { readonly from: string; readonly to: string };
378
+ ```
379
+
380
+ Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from` matches every path.
381
+
382
+ #### `HealthItem`
383
+
384
+ ```ts
385
+ export type HealthItem = {
386
+ readonly path: string;
387
+ /** Other paths it could be the same promise as, when any; Atlas has not merged them. */
388
+ readonly candidates?: readonly { readonly path: string; readonly reason: string }[];
389
+ };
390
+ ```
391
+
392
+ One thing worth a look.
393
+
394
+ #### `HealthCode`
395
+
396
+ ```ts
397
+ export type HealthCode =
398
+ | 'ambiguous-spec-path'
399
+ | 'unmatched-spec-path'
400
+ | 'duplicate-spec-content'
401
+ | 'duplicate-spec-title'
402
+ | 'generic-domain'
403
+ | 'tests-without-spec'
404
+ | 'no-spec-definitions'
405
+ | 'fallback-identity'
406
+ | 'unused-root-mapping'
407
+ | 'spec-without-execution'
408
+ | 'historical-only';
409
+ ```
410
+
411
+ Codes of `HealthFinding.code`. The set grows; a reader treats an unknown code as informational.
412
+
413
+ #### `HealthFinding`
414
+
415
+ ```ts
416
+ export type HealthFinding = {
417
+ readonly code: HealthCode;
418
+ /** `warning` is something that makes a count or a filter misleading; `info` explains a number. */
419
+ readonly severity: 'warning' | 'info';
420
+ readonly message: string;
421
+ /** How many paths, specs or tests it affects. */
422
+ readonly count: number;
423
+ /** Up to `HEALTH_ITEM_LIMIT` of them. */
424
+ readonly items: readonly HealthItem[];
425
+ };
426
+ ```
427
+
428
+ A finding: what is inconsistent, how many it affects, and what to change.
429
+
430
+ #### `SpecAlias`
431
+
432
+ ```ts
433
+ export type SpecAlias = {
434
+ /** The logical spec's path after mapping. */
435
+ readonly path: string;
436
+ /** Every original path it was seen under, sorted. */
437
+ readonly sources: readonly string[];
438
+ };
439
+ ```
440
+
441
+ A spec that reached Atlas under more than one path and was joined by a declared root mapping.
442
+
443
+ #### `ReconciliationStep`
444
+
445
+ ```ts
446
+ export type ReconciliationStep = {
447
+ readonly code: 'merged-by-mapping' | 'defined-without-results' | 'results-without-definition';
448
+ readonly label: string;
449
+ /** The change to the running total: negative removes specs, positive adds them. */
450
+ readonly delta: number;
451
+ };
452
+ ```
453
+
454
+ One step from the Specs count to the Tests count.
455
+
456
+ #### `CatalogHealth`
457
+
458
+ ```ts
459
+ export type CatalogHealth = {
460
+ readonly schemaVersion: typeof CATALOG_HEALTH_VERSION;
461
+ readonly version: string;
462
+ readonly generatedAtMs: number;
463
+ /** True when there is no warning. Informational findings do not count. */
464
+ readonly ok: boolean;
465
+ readonly counts: {
466
+ readonly specs: {
467
+ /** Spec files the report is laid over (before mapping). */
468
+ readonly files: number;
469
+ /** Logical specs among them: files joined by a root mapping count once. */
470
+ readonly defined: number;
471
+ /** Logical specs, defined or not, with at least one test. */
472
+ readonly withTests: number;
473
+ /** Defined logical specs with a test that has a passed or failed result. They are the denominator of execution coverage. */
474
+ readonly executed: number;
475
+ /** Defined logical specs with no test that has a passed or failed result: the defined coverage gaps. */
476
+ readonly withoutExecution: number;
477
+ };
478
+ readonly tests: {
479
+ readonly total: number;
480
+ readonly withSpec: number;
481
+ readonly withoutSpec: number;
482
+ /** Tests whose current status is `passed`. A definition with no test never adds to it. */
483
+ readonly passed: number;
484
+ /** Tests the history holds but the report has no current result for. */
485
+ readonly historicalOnly: number;
486
+ };
487
+ readonly scenarios: {
488
+ /** Outlines: tests sharing the label `outline` count once; a test without it is its own outline. */
489
+ readonly outlines: number;
490
+ /** Concrete examples: tests (a check identity), however many environments ran them. */
491
+ readonly examples: number;
492
+ /** Current results per producer, environment and parameter set. */
493
+ readonly observations: number;
494
+ /** Observations that passed or failed. */
495
+ readonly executedObservations: number;
496
+ readonly passedObservations: number;
497
+ };
498
+ };
499
+ …
500
+ ```
501
+
502
+ The `GET /api/reports/tests/health` document.
503
+
504
+ #### `parseRootMapping`
505
+
506
+ ```ts
507
+ export function parseRootMapping(text: string): RootMapping | null
508
+ ```
509
+
510
+ Parses `from>to` (as `?root=` gives it) into a mapping. Returns null when there is no `>`. Whitespace around each side is trimmed.
511
+
512
+ #### `parseRootMappings`
513
+
514
+ ```ts
515
+ export function parseRootMappings(params: URLSearchParams): RootMapping[]
516
+ ```
517
+
518
+ Reads the repeatable `root` search parameter: each value `from>to`, several may be comma-separated. Values without `>` are ignored.
519
+
520
+ #### `canonicalSpecPath`
521
+
522
+ ```ts
523
+ export function canonicalSpecPath(path: string, roots: readonly RootMapping[]): { readonly path: string; readonly mapping: RootMapping | null }
524
+ ```
525
+
526
+ The logical path of a spec path: the path Atlas already normalises (`specPath`), after the first mapping whose `from`
527
+ is a prefix of it (the longest `from` wins). A path already under `to` is left alone. Returns the path and the mapping
528
+ that changed it, if any.
529
+
530
+ #### `catalogHealth`
531
+
532
+ ```ts
533
+ export function catalogHealth(model: CatalogModel, roots: readonly RootMapping[] = []): CatalogHealth
534
+ ```
535
+
536
+ Diagnoses a report's spec identity, domains and counts. `roots` are the declared path-root mappings. Pure: the same
537
+ model and mappings give the same document, and nothing in it changes what the report says passed.
538
+
539
+ **Example**
540
+
541
+ ```ts
542
+ const health = catalogHealth(model, [{ from: '', to: 'specs/' }]);
543
+ if (!health.ok) console.log(healthMarkdown(health));
544
+ ```
545
+
546
+ #### `healthMarkdown`
547
+
548
+ ```ts
549
+ export function healthMarkdown(health: CatalogHealth): string
550
+ ```
551
+
552
+ The health document as Markdown, for an agent or a terminal.
553
+
333
554
  ### Catalog search, filter, group and sort
334
555
 
335
556
  Source: `packages/reports/src/catalog.ts`. Import: `@crvouga/atlas/reports/catalog`
@@ -7564,6 +7785,7 @@ const catalog = testCatalog(catalogModel(layers, { history }));
7564
7785
 
7565
7786
  - `get rev(): number { return this.#rev; }` — Changes each time a layer is added.
7566
7787
  - `get specs(): RecordedSpecs { return this.#specs; }` — The specs the added layers recorded.
7788
+ - `tests(): readonly { readonly key: string; readonly id: string; readonly specPath: string | null }[]` — Every test the history holds a result for (keyed as `TestSummary.key`), including tests the report no longer has a current result for.
7567
7789
  - `get layers(): number { return this.#seen.size; }` — How many layers were added.
7568
7790
  - `add(layer: LayerHeader, rows: readonly LayerRow[], specs: readonly SpecEntry[] = [], backend = layer.backend): void` — Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored.
7569
7791
  - `of(key: string): readonly TestResultEntry[]` — The recorded results of one test (by `TestSummary.key`), newest first. Empty when none.
@@ -7616,6 +7838,8 @@ export type CatalogModel = {
7616
7838
  readonly facets: CatalogFacets;
7617
7839
  readonly haystacks: ReadonlyMap<string, string>;
7618
7840
  readonly history: TestHistory | null;
7841
+ /** The specs the report is laid over, sorted by path: the given ones, else those the layers recorded. What `catalogHealth` calls defined. Absent on a model built from a server's catalog document. */
7842
+ readonly specs?: readonly SpecEntry[];
7619
7843
  };
7620
7844
  ```
7621
7845
 
@@ -234,6 +234,16 @@ export const TestCatalogSchema = …
234
234
  `GET /api/reports/tests`: every test of the report with a summary, insights and facets. The summary and facets cover
235
235
  every test; `tests` is the page of those the query matched.
236
236
 
237
+ #### `CatalogHealthSchema`
238
+
239
+ ```ts
240
+ export const CatalogHealthSchema = …
241
+ ```
242
+
243
+ `GET /api/reports/tests/health`: why the report's Specs, domains and counts may disagree. Counts keep the denominators
244
+ apart (spec files, defined specs, specs with an executed test, tests, outlines, examples, observations), `reconciliation`
245
+ walks from one to another, and `findings` name what to fix. It reads the report; it never changes what passed.
246
+
237
247
  #### `TestHistorySchema`
238
248
 
239
249
  ```ts
@@ -607,7 +607,7 @@ Wrap the single-project protocol in project selection. Auth, upload validation a
607
607
  Source: `packages/server/src/settings-page.ts`
608
608
 
609
609
  @module Settings page
610
- A standalone administrator form, styled with the Atlas client's shared design tokens.
610
+ The administrator's React and Mantine entry point, bundled lazily for API-only servers too.
611
611
  Credentials remain in page memory; the settings API owns authorization and validation.
612
612
 
613
613
  #### `SETTINGS_PAGE`
@@ -616,7 +616,7 @@ Credentials remain in page memory; the settings API owns authorization and valid
616
616
  export const SETTINGS_PAGE = …
617
617
  ```
618
618
 
619
- The settings form and its browser controller, also usable without a built client.
619
+ Document shell for the shared React settings UI. No configuration or credentials are embedded.
620
620
 
621
621
  #### `renderSettingsPage`
622
622
 
@@ -624,8 +624,8 @@ The settings form and its browser controller, also usable without a built client
624
624
  export function renderSettingsPage(): Promise<string>
625
625
  ```
626
626
 
627
- Render Settings using the exact design tokens shipped with the Atlas client. Loads and caches the stylesheet
628
- on first request, never at import time. Minimal neutral tokens keep embedded API-only servers usable too.
627
+ Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
628
+ without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
629
629
 
630
630
  **Example**
631
631