@arjunkhera/atlas 0.3.7 → 0.3.9

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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/artifact-renderer.md +22 -22
  3. package/agents/verifier.md +29 -0
  4. package/door/cli.mjs +62 -12
  5. package/door/kit-releases.json +4 -0
  6. package/door/lib/design-build.mjs +409 -0
  7. package/door/lib/design.mjs +199 -118
  8. package/door/lib/markdown.mjs +160 -0
  9. package/door/lib/proof.mjs +127 -0
  10. package/door/lib/tests.mjs +190 -0
  11. package/package.json +2 -1
  12. package/skills/lead/SKILL.md +4 -1
  13. package/skills/sdlc-task/SKILL.md +33 -24
  14. package/skills/sdlc-task/design/README.md +187 -0
  15. package/skills/sdlc-task/design/parts/actors.md +26 -0
  16. package/skills/sdlc-task/design/parts/alternatives.md +24 -0
  17. package/skills/sdlc-task/design/parts/build.md +23 -0
  18. package/skills/sdlc-task/design/parts/calls.md +25 -0
  19. package/skills/sdlc-task/design/parts/change.md +27 -0
  20. package/skills/sdlc-task/design/parts/data.md +22 -0
  21. package/skills/sdlc-task/design/parts/done.md +23 -0
  22. package/skills/sdlc-task/design/parts/edges.md +24 -0
  23. package/skills/sdlc-task/design/parts/goals.md +27 -0
  24. package/skills/sdlc-task/design/parts/key.md +25 -0
  25. package/skills/sdlc-task/design/parts/migration.md +22 -0
  26. package/skills/sdlc-task/design/parts/order.md +24 -0
  27. package/skills/sdlc-task/design/parts/problem.md +22 -0
  28. package/skills/sdlc-task/design/parts/proof.md +24 -0
  29. package/skills/sdlc-task/design/parts/proposal.md +24 -0
  30. package/skills/sdlc-task/design/parts/records.md +24 -0
  31. package/skills/sdlc-task/design/parts/repos.md +25 -0
  32. package/skills/sdlc-task/design/parts/risks.md +24 -0
  33. package/skills/sdlc-task/design/parts/rollout.md +24 -0
  34. package/skills/sdlc-task/design/parts/routes.md +24 -0
  35. package/skills/sdlc-task/design/parts/scorecard.md +25 -0
  36. package/skills/sdlc-task/design/parts/security.md +22 -0
  37. package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
  38. package/skills/sdlc-task/design/parts/states.md +25 -0
  39. package/skills/sdlc-task/design/parts/stories.md +26 -0
  40. package/skills/sdlc-task/design/parts/summary.md +33 -0
  41. package/skills/sdlc-task/design/parts/why.md +22 -0
  42. package/skills/sdlc-task/design/parts/words.md +29 -0
  43. package/skills/sdlc-task/design/parts/yardstick.md +25 -0
  44. package/skills/sdlc-task/design/parts.yaml +306 -0
  45. package/skills/sdlc-task/lifecycle.yaml +2 -2
  46. package/skills/tests/SKILL.md +234 -0
  47. package/tests/contract.mjs +398 -0
  48. package/tests/drivers/function.mjs +30 -0
  49. package/tests/drivers/http.mjs +68 -0
  50. package/tests/drivers/index.mjs +65 -0
  51. package/tests/drivers/mcp-stdio.mjs +174 -0
  52. package/tests/environment.mjs +227 -0
  53. package/tests/errors.mjs +27 -0
  54. package/tests/evidence.mjs +113 -0
  55. package/tests/fresh.mjs +42 -0
  56. package/tests/guards.mjs +159 -0
  57. package/tests/index.mjs +11 -0
  58. package/tests/link-check.mjs +576 -0
  59. package/tests/procs.mjs +43 -0
  60. package/tests/redact.mjs +58 -0
  61. package/tests/scenario.mjs +325 -0
  62. package/tests/stand-in.mjs +74 -0
  63. package/tests/tests-yaml.mjs +258 -0
  64. package/tests/wait.mjs +44 -0
  65. package/tests/yaml.mjs +327 -0
  66. package/agents/artifact-format/walkthrough.html +0 -706
  67. package/skills/sdlc-task/templates/design-doc.md +0 -126
@@ -0,0 +1,409 @@
1
+ // The design page builder. It turns one design folder into one page, the
2
+ // same way every time: a left rail that folds as a whole and per group, a
3
+ // fixed top bar, a fixed Back and Next bar, the arrow keys, and a light and
4
+ // a dark theme from one token set.
5
+ //
6
+ // A design keeps no state. The page shows the tracker's facts only from the
7
+ // JSON file the lead passes with --tracker, and the builder makes no network
8
+ // call. Each field is a field of a tracker record, copied as the Atlas tools
9
+ // return it; the builder refuses any other field. Approval and lock, and
10
+ // Since approval, are derived from those fields, never written by hand:
11
+ //
12
+ // { "read_at": "2026-10-09T13:30:00Z",
13
+ // "item": { "id", "title", "lifecycle", "stage", "tier",
14
+ // "locked_at": "<ISO time>" | null,
15
+ // "origin": { "text", "source", "date" } }, the owner's words; at a lock, the approval
16
+ // "questions": [ { "id", "text", "status": "open" | "answered", "answer": "<the answer's decision text>" } ],
17
+ // "decisions": [ { "id", "kind", "date", "text", "source" } ],
18
+ // "links": [ { "kind", "url", "role", "note" } ],
19
+ // "family": [ { "id", "title", "lifecycle", "url" } ] }
20
+ //
21
+ // Every field may be left out, except that the file is one object.
22
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
23
+ import { dirname, basename } from 'node:path';
24
+ import { readDesign, loadRegistry, checkDesign } from './design.mjs';
25
+ import { render, esc } from './markdown.mjs';
26
+
27
+ export const WOODCUT = 'https://cdn.jsdelivr.net/npm/@arjunkhera/woodcut@0.4.0/src/index.js';
28
+
29
+ // One token set. Each name has a light value and a dark value.
30
+ export const TOKENS = Object.freeze({
31
+ paper: ['#FAF9F5', '#1F1E1B'], ink: ['#141413', '#ECE9E1'], card: ['#FFFFFF', '#2A2925'], rail: ['#F4F1EA', '#262521'],
32
+ line: ['#E6E1D6', '#3A3833'], 'line-soft': ['#EEE9DF', '#33312C'], 'line-strong': ['#DDD6C8', '#45423B'],
33
+ faint: ['#C9C1B2', '#57534B'], sep: ['#B9B2A4', '#6E695F'], num: ['#A39A8A', '#857D70'], quiet: ['#7D776C', '#9A9387'],
34
+ muted: ['#5E5A52', '#B3AC9F'], body: ['#3A3732', '#D4CFC4'],
35
+ accent: ['#B5502F', '#E08A63'], 'accent-ink': ['#8A3A20', '#F0B394'], 'accent-bg': ['#FBEFE8', '#3B2A22'], 'accent-line': ['#E9C2B0', '#6B4433'],
36
+ 'trk-ink': ['#22496B', '#9CC3E6'], 'trk-bg': ['#F2F5F8', '#1E2833'], 'trk-line': ['#C9D6E3', '#34475A'], 'trk-label': ['#4F6C86', '#8FA9C2'],
37
+ yes: ['#2F5D3A', '#8FCB9C'], no: ['#A3352B', '#F08A7E'], warn: ['#8A5A12', '#E3B865'],
38
+ 'btn-bg': ['#141413', '#ECE9E1'], 'btn-ink': ['#FFFFFF', '#1F1E1B'],
39
+ });
40
+
41
+ const tokenBlock = (index) => Object.entries(TOKENS).map(([name, pair]) => `--${name}:${pair[index]}`).join(';');
42
+
43
+ const CSS = `
44
+ :root{${tokenBlock(0)};color-scheme:light;
45
+ --wc-paper:var(--paper);--wc-ink:var(--ink);--wc-body:var(--body);--wc-muted:var(--muted);--wc-faint:var(--quiet);--wc-line:var(--line);--wc-line-strong:var(--line-strong);--wc-accent:var(--accent);--wc-accent-soft:var(--accent-bg);--wc-panel:var(--rail);--wc-card:var(--card);--wc-danger:var(--no);--wc-danger-soft:var(--accent-bg);
46
+ --wc-serif:Lora,Georgia,serif;--wc-mono:'IBM Plex Mono',ui-monospace,Menlo,monospace;--wc-dur-fast:150ms;--wc-dur-base:220ms;--wc-dur-step:450ms;--wc-dur-draw:700ms;
47
+ --serif:Lora,Georgia,serif;--sans:Poppins,system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,Menlo,monospace}
48
+ :root[data-theme="dark"]{${tokenBlock(1)};color-scheme:dark}
49
+ *{box-sizing:border-box}
50
+ html,body{margin:0;height:100%}
51
+ body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.6;overflow:hidden}
52
+ a{color:var(--accent);text-underline-offset:3px}
53
+ button{font:inherit;cursor:pointer}
54
+ .app{display:flex;height:100vh;height:100dvh}
55
+ .rail{flex:0 0 272px;width:272px;height:100%;overflow-y:auto;border-right:1px solid var(--line);background:var(--rail);padding:18px 14px 40px;display:flex;flex-direction:column;gap:14px}
56
+ .rail.mini{flex-basis:52px;width:52px;padding:18px 0;align-items:center;gap:4px}
57
+ .rail .full{display:flex;flex-direction:column;gap:14px}
58
+ .rail.mini .full,.rail:not(.mini) .strip{display:none}
59
+ .strip{display:flex;flex-direction:column;align-items:center;gap:4px}
60
+ .railhead{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
61
+ .railhead b{font-family:var(--serif);font-weight:600;font-size:17px;line-height:1.3}
62
+ .lbl{font-family:var(--mono);font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
63
+ .sq{border:1px solid var(--line-strong);background:var(--card);border-radius:6px;padding:2px 9px;color:var(--body);font-size:14px}
64
+ .pill{border:1px solid var(--line-strong);background:var(--card);border-radius:99px;padding:2px 10px;color:var(--body);font-size:12px}
65
+ .row{display:flex;gap:6px;flex-wrap:wrap}
66
+ .group{display:flex;flex-direction:column;gap:2px}
67
+ .ghead{font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;text-align:left;display:flex;gap:8px;align-items:center;width:100%;padding:6px 8px;border:0;background:transparent;color:var(--muted)}
68
+ .ghead .n{font-family:var(--mono);font-size:11px;color:var(--quiet);margin-left:auto}
69
+ .group.ro .ghead,.item.ro,.mini-item.ro{color:var(--trk-ink)}
70
+ .item{font-size:14px;line-height:1.35;text-align:left;display:flex;gap:10px;align-items:baseline;width:100%;padding:6px 10px;border:0;border-radius:6px;border-left:3px solid transparent;background:transparent;color:var(--body)}
71
+ .item .n{font-family:var(--mono);font-size:11px;min-width:18px;color:var(--num)}
72
+ .item.on{border-left-color:var(--accent);background:var(--accent-bg);color:var(--accent-ink)}
73
+ .item.on .n{color:var(--accent)}
74
+ .group.shut .item{display:none}
75
+ .mini-item{font-family:var(--mono);font-size:11px;width:30px;height:24px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted)}
76
+ .mini-item.on{border-color:var(--accent);background:var(--accent-bg);color:var(--accent-ink)}
77
+ main{flex:1 1 auto;min-width:0;height:100%;display:flex;flex-direction:column}
78
+ .bar{flex:none;padding:0 40px;background:var(--paper)}
79
+ .bar.top{border-bottom:1px solid var(--line)}
80
+ .bar.foot{border-top:1px solid var(--line)}
81
+ .inner{max-width:1000px;margin:0 auto}
82
+ nav.inner{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:14px 0;font-size:14px;color:var(--body)}
83
+ nav .sep{color:var(--sep)}
84
+ nav .tag{margin-left:auto}
85
+ .tag{display:inline-block;font-family:var(--mono);font-size:11px;padding:1px 8px;border-radius:99px;border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap}
86
+ #scroll{flex:1 1 auto;min-height:0;overflow-y:auto;padding:26px 40px 48px}
87
+ #scroll>.inner{display:flex;flex-direction:column;gap:26px}
88
+ h1{margin:0;font-family:var(--serif);font-weight:600;font-size:42px;line-height:1.12}
89
+ .trkstrip{display:flex;flex-wrap:wrap;align-items:center;gap:22px;background:var(--trk-bg);border:1px solid var(--trk-line);border-radius:8px;padding:10px 16px}
90
+ .trkh{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--trk-ink)}
91
+ .kv{display:flex;flex-direction:column}
92
+ .kv span:first-child{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--trk-label)}
93
+ .kv span:last-child{font-size:14px;color:var(--trk-ink);font-weight:500}
94
+ .pos{display:flex;align-items:center;gap:10px;padding-top:6px;font-family:var(--mono);font-size:12px;color:var(--quiet)}
95
+ .pos::after{content:"";flex:1;height:1px;background:var(--line)}
96
+ .part{display:flex;flex-direction:column;gap:18px}
97
+ .part[hidden]{display:none}
98
+ .part h2{margin:0;font-family:var(--serif);font-weight:600;font-size:32px;line-height:1.2}
99
+ .part h3{margin:8px 0 0;font-family:var(--serif);font-weight:600;font-size:21px}
100
+ .part h4{margin:4px 0 0;font-size:16px;font-weight:600}
101
+ .part p,.part ul,.part ol{margin:0;max-width:780px}
102
+ .part ul,.part ol{padding-left:22px;display:flex;flex-direction:column;gap:6px}
103
+ .holds{font-size:18px;color:var(--body)}
104
+ .part.ro .holds{color:var(--trk-ink)}
105
+ .tablewrap{overflow-x:auto}
106
+ table{width:100%;border-collapse:collapse;font-size:15px}
107
+ th{text-align:left;font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);font-weight:500;padding:8px 10px;border-bottom:1px solid var(--line-strong)}
108
+ td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:top}
109
+ code{font-family:var(--mono);font-size:.88em;background:var(--rail);border:1px solid var(--line);border-radius:4px;padding:0 4px}
110
+ pre.code{margin:0;font-family:var(--mono);font-size:13px;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px 14px;overflow-x:auto;line-height:1.55}
111
+ pre.code code{background:none;border:0;padding:0}
112
+ blockquote{margin:0;border-left:3px solid var(--accent);padding:6px 0 6px 16px;font-family:var(--serif);font-size:19px;line-height:1.5;display:flex;flex-direction:column;gap:10px}
113
+ .path{font-family:var(--mono);font-size:.9em;color:var(--muted)}
114
+ .fig{margin:0;border-top:1px solid var(--line-strong);border-bottom:1px solid var(--line-strong);padding:12px 0}
115
+ .trk{background:var(--trk-bg);border:1px solid var(--trk-line);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}
116
+ .trk .trkh{font-size:11px}
117
+ .trk td,.trk th{border-color:var(--trk-line)}
118
+ .none{color:var(--muted);font-style:italic}
119
+ .draft{margin:0;border:1px solid var(--accent);background:var(--accent-bg);color:var(--accent-ink);border-radius:8px;padding:10px 14px}
120
+ .st{font-family:var(--mono);font-size:12px}
121
+ .st.open{color:var(--warn)}.st.answered,.st.done,.st.delivered{color:var(--yes)}
122
+ .meter{height:10px;border-radius:99px;background:var(--line);overflow:hidden;display:flex}
123
+ .meter i{display:block;height:100%;background:var(--yes)}
124
+ .foot .inner{display:flex;align-items:center;gap:12px;padding:12px 0}
125
+ .nav{font-size:14px;border-radius:8px;padding:6px 14px;width:240px;max-width:40vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
126
+ #prev{text-align:left;border:1px solid var(--line-strong);background:var(--card);color:var(--ink)}
127
+ #next{text-align:right;border:0;background:var(--btn-bg);color:var(--btn-ink)}
128
+ .nav:disabled{cursor:default;color:var(--faint)}
129
+ #next:disabled{background:var(--line);color:var(--num)}
130
+ .mid{flex:1;text-align:center;font-family:var(--mono);font-size:12px;color:var(--quiet);white-space:nowrap}
131
+ kbd{border:1px solid var(--line-strong);border-radius:4px;padding:0 5px;font-family:var(--mono)}
132
+ @media (max-width:760px){
133
+ .bar,#scroll{padding-left:16px;padding-right:16px}
134
+ .rail:not(.mini){position:fixed;z-index:5;left:0;top:0;bottom:0;box-shadow:0 0 0 100vmax rgba(0,0,0,.25)}
135
+ h1{font-size:30px}.part h2{font-size:25px}
136
+ .mid .keys{display:none}
137
+ }
138
+ @media print{body{overflow:visible}.app{display:block;height:auto}.rail,.bar.foot{display:none}#scroll{overflow:visible}.part[hidden]{display:flex}}
139
+ `;
140
+
141
+ const SCRIPT = `
142
+ (function () {
143
+ var data = JSON.parse(document.getElementById('design-data').textContent);
144
+ var flat = [];
145
+ data.groups.forEach(function (g) { g.items.forEach(function (it) { flat.push(it); }); });
146
+ var root = document.documentElement;
147
+ var store = {
148
+ get: function (k) { try { return window.localStorage.getItem('atlas-design:' + k); } catch (e) { return null; } },
149
+ set: function (k, v) { try { window.localStorage.setItem('atlas-design:' + k, v); } catch (e) {} }
150
+ };
151
+ var dark = false;
152
+ try { dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; } catch (e) {}
153
+ var theme = store.get('theme') || (dark ? 'dark' : 'light');
154
+ var themeBtn = document.getElementById('theme');
155
+ function setTheme(t) { theme = t; root.setAttribute('data-theme', t); themeBtn.textContent = t === 'dark' ? '\\u2600 Light' : '\\u263E Dark'; }
156
+ setTheme(theme);
157
+ themeBtn.addEventListener('click', function () { setTheme(theme === 'dark' ? 'light' : 'dark'); store.set('theme', theme); });
158
+
159
+ var rail = document.getElementById('rail');
160
+ var narrow = false;
161
+ try { narrow = window.matchMedia('(max-width: 760px)').matches; } catch (e) {}
162
+ if (narrow || store.get('rail') === 'mini') rail.classList.add('mini');
163
+ document.querySelectorAll('[data-rail]').forEach(function (b) {
164
+ b.addEventListener('click', function () { rail.classList.toggle('mini'); store.set('rail', rail.classList.contains('mini') ? 'mini' : 'open'); });
165
+ });
166
+ document.querySelectorAll('.group .ghead').forEach(function (b) {
167
+ b.addEventListener('click', function () { var g = b.parentNode; g.classList.toggle('shut'); b.setAttribute('aria-expanded', String(!g.classList.contains('shut'))); b.querySelector('.chev').textContent = g.classList.contains('shut') ? '\\u25B8' : '\\u25BE'; });
168
+ });
169
+ function setAll(open) { document.querySelectorAll('.group').forEach(function (g) { g.classList.toggle('shut', !open); var b = g.querySelector('.ghead'); b.setAttribute('aria-expanded', String(open)); b.querySelector('.chev').textContent = open ? '\\u25BE' : '\\u25B8'; }); }
170
+ document.getElementById('open-all').addEventListener('click', function () { setAll(true); });
171
+ document.getElementById('close-all').addEventListener('click', function () { setAll(false); });
172
+
173
+ var scroll = document.getElementById('scroll');
174
+ var prev = document.getElementById('prev');
175
+ var next = document.getElementById('next');
176
+ var idx = 0;
177
+ function go(i, keepHash) {
178
+ if (i < 0 || i >= flat.length) return;
179
+ idx = i;
180
+ var cur = flat[i];
181
+ document.querySelectorAll('.part').forEach(function (s) { s.hidden = s.getAttribute('data-part') !== cur.id; });
182
+ document.querySelectorAll('[data-go]').forEach(function (b) {
183
+ var on = b.getAttribute('data-go') === cur.id;
184
+ b.classList.toggle('on', on);
185
+ if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
186
+ });
187
+ document.getElementById('pos').textContent = 'PART ' + (i + 1) + ' OF ' + flat.length + ' \\u00B7 ' + cur.name.toUpperCase() + (cur.ro ? ' \\u00B7 FROM THE TRACKER' : '');
188
+ document.getElementById('count').textContent = (i + 1) + ' / ' + flat.length;
189
+ prev.disabled = i === 0; next.disabled = i === flat.length - 1;
190
+ prev.textContent = '\\u2190 ' + (i > 0 ? flat[i - 1].name : 'Start');
191
+ next.textContent = (i < flat.length - 1 ? flat[i + 1].name : 'End') + ' \\u2192';
192
+ scroll.scrollTop = 0;
193
+ if (!keepHash) { try { history.replaceState(null, '', '#' + cur.id); } catch (e) {} }
194
+ if (narrow) rail.classList.add('mini');
195
+ }
196
+ function find(id) { for (var i = 0; i < flat.length; i++) if (flat[i].id === id) return i; return -1; }
197
+ document.querySelectorAll('[data-go]').forEach(function (b) { b.addEventListener('click', function () { go(find(b.getAttribute('data-go'))); }); });
198
+ document.addEventListener('click', function (ev) {
199
+ var a = ev.target.closest && ev.target.closest('a[data-part]');
200
+ if (!a) return;
201
+ var i = find(a.getAttribute('data-part'));
202
+ if (i >= 0) { ev.preventDefault(); go(i); }
203
+ });
204
+ prev.addEventListener('click', function () { go(idx - 1); });
205
+ next.addEventListener('click', function () { go(idx + 1); });
206
+ document.addEventListener('keydown', function (ev) {
207
+ if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return;
208
+ var t = ev.target;
209
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
210
+ if (ev.key === 'ArrowRight') { ev.preventDefault(); go(idx + 1); }
211
+ if (ev.key === 'ArrowLeft') { ev.preventDefault(); go(idx - 1); }
212
+ });
213
+ var start = find((location.hash || '').slice(1));
214
+ go(start >= 0 ? start : 0, start < 0);
215
+ })();
216
+ `;
217
+
218
+ const dateOf = (value) => (value ? String(value).slice(0, 10) : '');
219
+ const stateTag = (value) => `<span class="st ${esc(String(value ?? '').toLowerCase())}">${esc(value ?? '')}</span>`;
220
+ const table = (head, rows) => `<div class="tablewrap"><table><thead><tr>${head.map((cell) => `<th>${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
221
+ const linkTo = (label, url) => (url && /^https?:\/\//.test(url) ? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label));
222
+
223
+ // The tracker file's shape: each key and the type of its value. A key that
224
+ // is not here is refused, so a hand-made field cannot reach the page.
225
+ const SHAPE = {
226
+ top: { read_at: 'string', item: 'object', questions: 'array', decisions: 'array', links: 'array', family: 'array' },
227
+ item: { id: 'string', title: 'string', lifecycle: 'string', stage: 'string', tier: 'string', locked_at: 'string|null', origin: 'object|null' },
228
+ origin: { text: 'string', source: 'string', date: 'string' },
229
+ questions: { id: 'string', text: 'string', status: 'string', answer: 'string|null' },
230
+ decisions: { id: 'string', kind: 'string', date: 'string', text: 'string', source: 'string' },
231
+ links: { kind: 'string', url: 'string', role: 'string', note: 'string' },
232
+ family: { id: 'string', title: 'string', lifecycle: 'string', url: 'string' },
233
+ };
234
+ const typeOf = (value) => (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value);
235
+
236
+ export function validateTracker(data) {
237
+ const faults = [];
238
+ const fields = (value, shape, where) => {
239
+ if (typeOf(value) !== 'object') { faults.push(`${where} must be an object`); return; }
240
+ for (const [key, one] of Object.entries(value)) {
241
+ if (!Object.hasOwn(shape, key)) { faults.push(`${where}.${key} is not a tracker field`); continue; }
242
+ if (!shape[key].split('|').includes(typeOf(one))) faults.push(`${where}.${key} must be ${shape[key].replace('|', ' or ')}`);
243
+ }
244
+ };
245
+ fields(data, SHAPE.top, 'the tracker file');
246
+ if (faults.length) return faults;
247
+ if (data.item) {
248
+ fields(data.item, SHAPE.item, 'item');
249
+ if (data.item.origin) fields(data.item.origin, SHAPE.origin, 'item.origin');
250
+ }
251
+ for (const list of ['questions', 'decisions', 'links', 'family']) (data[list] ?? []).forEach((row, index) => fields(row, SHAPE[list], `${list}[${index}]`));
252
+ for (const row of data.questions ?? []) if (row.status !== undefined && !['open', 'answered'].includes(row.status)) faults.push(`a question's status must be open or answered, not "${row.status}"`);
253
+ return faults;
254
+ }
255
+
256
+ // The body of each tracker part, from the JSON. Never from the folder.
257
+ function trackerBody(id, tracker) {
258
+ if (!tracker) return '<p class="none">This page was built with no tracker data. The lead passes it with --tracker.</p>';
259
+ const item = tracker.item ?? {};
260
+ const questions = tracker.questions ?? [];
261
+ const decisions = tracker.decisions ?? [];
262
+ const family = tracker.family ?? [];
263
+ const lockedOn = item.locked_at ? dateOf(item.locked_at) : null;
264
+ const decisionRows = (rows) => rows.map((one) => `<div class="kv"><span>${esc(dateOf(one.date))}${one.kind ? ` · ${esc(one.kind)}` : ''}</span></div><blockquote><p>${esc(one.text)}</p></blockquote>`).join('\n');
265
+ switch (id) {
266
+ case 'questions':
267
+ return questions.length
268
+ ? table(['Question', 'State', 'Answer'], questions.map((one) => [esc(one.text), stateTag(one.status), esc(one.answer ?? '')]))
269
+ : '<p class="none">No questions.</p>';
270
+ case 'decisions':
271
+ return decisions.length ? decisionRows(decisions) : '<p class="none">No decisions yet.</p>';
272
+ case 'approval':
273
+ if (!lockedOn) return '<p class="none">Not approved yet. The owner approves the design in his own words, and the lock records them.</p>';
274
+ return `${item.origin?.text ? `<blockquote><p>${esc(item.origin.text)}</p></blockquote>` : ''}<p>Approved and locked ${esc(lockedOn)}${item.origin?.source ? `, ${esc(item.origin.source)}` : ''}.</p>`;
275
+ case 'since': {
276
+ if (!lockedOn) return '<p class="none">Not approved yet, so nothing has changed since approval.</p>';
277
+ const after = decisions.filter((one) => dateOf(one.date) > lockedOn);
278
+ return after.length ? decisionRows(after) : '<p class="none">No change since approval.</p>';
279
+ }
280
+ case 'family':
281
+ return family.length
282
+ ? table(['Design', 'State'], family.map((one) => [linkTo(one.title ?? one.id ?? '', one.url), stateTag(one.lifecycle)]))
283
+ : '<p class="none">No linked designs.</p>';
284
+ case 'rollup': {
285
+ if (!family.length) return '<p class="none">No linked designs.</p>';
286
+ const done = family.filter((one) => one.lifecycle === 'done').length;
287
+ const counts = new Map();
288
+ for (const one of family) counts.set(one.lifecycle, (counts.get(one.lifecycle) ?? 0) + 1);
289
+ return `<p>${done} of ${family.length} designs done.</p><div class="meter" role="img" aria-label="${done} of ${family.length} done"><i style="width:${Math.round((100 * done) / family.length)}%"></i></div>${table(['State', 'Designs'], [...counts].map(([state, count]) => [stateTag(state), String(count)]))}`;
290
+ }
291
+ default: return '';
292
+ }
293
+ }
294
+
295
+ // A design with check findings builds only as a draft, and the page says so.
296
+ export function buildDesign(folder, { tracker = null, draft = false, registry = loadRegistry() } = {}) {
297
+ if (tracker) {
298
+ const faults = validateTracker(tracker);
299
+ if (faults.length) throw new Error(`the tracker data is refused: ${faults.join('; ')}`);
300
+ }
301
+ const design = readDesign(folder);
302
+ const findings = checkDesign(design, registry);
303
+ if (findings.length && !draft) return { html: null, findings, parts: 0, groups: 0, figures: 0 };
304
+ const meta = design.meta ?? {};
305
+ const kinds = Array.isArray(meta.kinds) ? meta.kinds.filter((kind) => registry.kindIds.includes(kind)) : [];
306
+ const fileOf = new Map(design.parts.filter((one) => registry.byId.has(one.part)).map((one) => [one.part, one]));
307
+ const partByFile = new Map([...fileOf].map(([id, one]) => [one.file, id]));
308
+ const figures = [];
309
+
310
+ // The rail: each group with the parts this folder holds, in registry
311
+ // order, then the tracker group.
312
+ const groups = [];
313
+ for (const group of registry.groups) {
314
+ if (group.id === 'tracker') continue;
315
+ const items = registry.parts.filter((part) => part.group === group.id && fileOf.has(part.id)).map((part) => ({ id: part.id, name: part.name, ro: false }));
316
+ if (items.length) groups.push({ id: group.id, name: group.name, ro: false, items });
317
+ }
318
+ const trackerParts = registry.tracker.filter((row) => (row.id === 'rollup' ? kinds.includes('initiative') : row.id === 'family' ? kinds.includes('initiative') || (tracker?.family ?? []).length > 0 : true));
319
+ const trackerGroup = registry.groups.find((group) => group.id === 'tracker');
320
+ groups.push({ id: 'tracker', name: trackerGroup.name, ro: true, items: trackerParts.map((row) => ({ id: row.id, name: row.name, ro: true })) });
321
+
322
+ const sections = [];
323
+ let number = 0;
324
+ for (const group of groups) {
325
+ for (const item of group.items) {
326
+ number += 1;
327
+ if (item.ro) {
328
+ const row = registry.tracker.find((one) => one.id === item.id);
329
+ sections.push(`<section class="part ro" data-part="${item.id}" id="part-${item.id}" hidden><h2>${esc(row.name)}</h2><p class="holds">${esc(row.holds)}</p><div class="trk"><span class="trkh">From the tracker · read only${tracker?.read_at ? ` · read ${esc(String(tracker.read_at).replace('T', ' ').slice(0, 16))}` : ''}</span>${trackerBody(item.id, tracker)}</div></section>`);
330
+ continue;
331
+ }
332
+ const one = fileOf.get(item.id);
333
+ const ctx = { file: one.file, page: item.id, figures, partOf: (href) => partByFile.get(href.split('#')[0]) ?? null };
334
+ const { title, html } = render(one.body, ctx);
335
+ const heading = one.title ?? title ?? item.name;
336
+ sections.push(`<section class="part" data-part="${item.id}" id="part-${item.id}" hidden><h2>${esc(heading)}</h2>\n${html}\n</section>`);
337
+ }
338
+ }
339
+
340
+ const strip = tracker
341
+ ? [['Item', tracker.item?.title ?? tracker.item?.id ?? '—'], ['Lifecycle', tracker.item?.lifecycle ?? '—'], ['Stage', tracker.item?.stage ?? '—'],
342
+ ['Open questions', String((tracker.questions ?? []).filter((one) => one.status === 'open').length)], ['Read', String(tracker.read_at ?? '—').replace('T', ' ').slice(0, 16)]]
343
+ : [['Tracker', 'Not read for this build']];
344
+ const kindLabel = `${kinds.map((kind) => registry.kinds.find((one) => one.id === kind).name).join(' + ') || 'No kind'}${meta.shared ? ' · shared' : ''}`;
345
+ const title = meta.title ?? basename(design.root);
346
+ const crumbs = [meta.product, 'Design docs'].filter(Boolean).map((one) => `<span>${esc(one)}</span><span class="sep">/</span>`).join('');
347
+ const data = { groups: groups.map((group) => ({ id: group.id, items: group.items })) };
348
+ let n = 0;
349
+ const railGroups = groups.map((group) => `<div class="group${group.ro ? ' ro' : ''}"><button type="button" class="ghead" aria-expanded="true"><span class="chev">▾</span><span>${esc(group.name)}</span><span class="n">${group.items.length}</span></button>${group.items.map((item) => { n += 1; return `<button type="button" class="item${item.ro ? ' ro' : ''}" data-go="${item.id}"><span class="n">${n}</span><span>${esc(item.name)}</span></button>`; }).join('')}</div>`).join('\n');
350
+ let m = 0;
351
+ const miniItems = groups.flatMap((group) => group.items).map((item) => { m += 1; return `<button type="button" class="mini-item${item.ro ? ' ro' : ''}" data-go="${item.id}" title="${esc(item.name)}">${m}</button>`; }).join('');
352
+ const woodcut = figures.some((one) => one !== 'custom');
353
+
354
+ const html = `<!doctype html>
355
+ <html lang="en">
356
+ <head>
357
+ <meta charset="utf-8">
358
+ <meta name="viewport" content="width=device-width, initial-scale=1">
359
+ <title>${esc(title)}</title>
360
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,500;0,600;1,500&family=Poppins:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
361
+ <style>${CSS}</style>
362
+ <noscript><style>body{overflow:auto}.app{display:block;height:auto}.rail,.bar.foot,.pos{display:none}#scroll{overflow:visible}.part[hidden]{display:flex;margin-bottom:40px}</style></noscript>
363
+ </head>
364
+ <body>
365
+ <div class="app">
366
+ <aside class="rail" id="rail" aria-label="Parts of the design">
367
+ <div class="full">
368
+ <div class="railhead"><div class="kv"><span class="lbl">${esc(kindLabel)}</span><b>${esc(title)}</b></div><button type="button" class="sq" data-rail aria-label="Fold the side rail" title="Fold">«</button></div>
369
+ <div class="row"><button type="button" class="pill" id="open-all">Expand all</button><button type="button" class="pill" id="close-all">Collapse all</button></div>
370
+ ${railGroups}
371
+ </div>
372
+ <div class="strip"><button type="button" class="sq" data-rail aria-label="Open the side rail" title="Open" style="margin-bottom:10px">»</button>${miniItems}</div>
373
+ </aside>
374
+ <main>
375
+ <div class="bar top"><nav class="inner">${crumbs}<b style="font-weight:500">${esc(title)}</b><span class="tag">${esc(kindLabel.toUpperCase())}</span><button type="button" class="pill" id="theme" aria-label="Switch light or dark">☾ Dark</button></nav></div>
376
+ <div id="scroll"><div class="inner">
377
+ <header style="display:flex;flex-direction:column;gap:12px"><h1>${esc(title)}</h1>
378
+ <div class="trkstrip"><span class="trkh">From the tracker</span>${strip.map(([k, v]) => `<span class="kv"><span>${esc(k)}</span><span>${esc(v)}</span></span>`).join('')}</div></header>
379
+ ${findings.length ? `<p class="draft">Draft: <code>atlas design check</code> has ${findings.length} finding(s) on this design.</p>` : ''}
380
+ <div class="pos" id="pos"></div>
381
+ ${sections.join('\n')}
382
+ </div></div>
383
+ <div class="bar foot"><div class="inner"><button type="button" class="nav" id="prev" aria-label="Previous part"></button><span class="mid"><span id="count"></span><span class="keys"> · <kbd>←</kbd> <kbd>→</kbd> to move</span></span><button type="button" class="nav" id="next" aria-label="Next part"></button></div></div>
384
+ </main>
385
+ </div>
386
+ <script type="application/json" id="design-data">${JSON.stringify(data).replace(/<\//g, '<\\/')}</script>
387
+ ${woodcut ? `<script type="module" src="${WOODCUT}"></script>\n` : ''}<script>${SCRIPT}</script>
388
+ </body>
389
+ </html>
390
+ `;
391
+ return { html, findings, parts: number, groups: groups.length, figures: figures.length };
392
+ }
393
+
394
+ export function readTracker(path) {
395
+ let data;
396
+ try { data = JSON.parse(readFileSync(path, 'utf8')); }
397
+ catch (error) { throw new Error(`the tracker file ${path} is not JSON: ${error.message}`); }
398
+ const faults = validateTracker(data);
399
+ if (faults.length) throw new Error(`the tracker file ${path} is refused: ${faults.join('; ')}`);
400
+ return data;
401
+ }
402
+
403
+ export function writeDesignPage(folder, out, options = {}) {
404
+ const result = buildDesign(folder, options);
405
+ if (!result.html) return result;
406
+ mkdirSync(dirname(out), { recursive: true });
407
+ writeFileSync(out, result.html);
408
+ return result;
409
+ }