@arjunkhera/atlas 0.3.8 → 0.3.10

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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/artifact-renderer.md +22 -22
  3. package/door/cli.mjs +36 -11
  4. package/door/lib/design-build.mjs +409 -0
  5. package/door/lib/design.mjs +199 -118
  6. package/door/lib/markdown.mjs +160 -0
  7. package/package.json +1 -1
  8. package/skills/lead/SKILL.md +1 -1
  9. package/skills/sdlc-task/SKILL.md +33 -24
  10. package/skills/sdlc-task/design/README.md +187 -0
  11. package/skills/sdlc-task/design/parts/actors.md +26 -0
  12. package/skills/sdlc-task/design/parts/alternatives.md +24 -0
  13. package/skills/sdlc-task/design/parts/build.md +23 -0
  14. package/skills/sdlc-task/design/parts/calls.md +25 -0
  15. package/skills/sdlc-task/design/parts/change.md +27 -0
  16. package/skills/sdlc-task/design/parts/data.md +22 -0
  17. package/skills/sdlc-task/design/parts/done.md +23 -0
  18. package/skills/sdlc-task/design/parts/edges.md +24 -0
  19. package/skills/sdlc-task/design/parts/goals.md +27 -0
  20. package/skills/sdlc-task/design/parts/key.md +25 -0
  21. package/skills/sdlc-task/design/parts/migration.md +22 -0
  22. package/skills/sdlc-task/design/parts/order.md +24 -0
  23. package/skills/sdlc-task/design/parts/problem.md +22 -0
  24. package/skills/sdlc-task/design/parts/proof.md +24 -0
  25. package/skills/sdlc-task/design/parts/proposal.md +24 -0
  26. package/skills/sdlc-task/design/parts/records.md +24 -0
  27. package/skills/sdlc-task/design/parts/repos.md +25 -0
  28. package/skills/sdlc-task/design/parts/risks.md +24 -0
  29. package/skills/sdlc-task/design/parts/rollout.md +24 -0
  30. package/skills/sdlc-task/design/parts/routes.md +24 -0
  31. package/skills/sdlc-task/design/parts/scorecard.md +25 -0
  32. package/skills/sdlc-task/design/parts/security.md +22 -0
  33. package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
  34. package/skills/sdlc-task/design/parts/states.md +25 -0
  35. package/skills/sdlc-task/design/parts/stories.md +26 -0
  36. package/skills/sdlc-task/design/parts/summary.md +33 -0
  37. package/skills/sdlc-task/design/parts/why.md +22 -0
  38. package/skills/sdlc-task/design/parts/words.md +29 -0
  39. package/skills/sdlc-task/design/parts/yardstick.md +25 -0
  40. package/skills/sdlc-task/design/parts.yaml +306 -0
  41. package/skills/sdlc-task/lifecycle.yaml +2 -2
  42. package/work/lib/verbs.mjs +99 -14
  43. package/work/mcp.mjs +1 -1
  44. package/agents/artifact-format/walkthrough.html +0 -706
  45. package/skills/sdlc-task/templates/design-doc.md +0 -126
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -2,8 +2,8 @@
2
2
  name: artifact-renderer
3
3
  description: >
4
4
  Dedicated Sonnet subagent that renders a design doc, PRD, review, or task explainer into
5
- the owner-ratified HTML artifact treatment: one light theme in the
6
- Claude-website family, written for a smart newcomer ("assume like a fresher"), diagram-rich,
5
+ the owner-ratified HTML artifact treatment (one light theme in the
6
+ Claude-website family), or draws one custom figure for a design part, written for a smart newcomer ("assume like a fresher"), diagram-rich,
7
7
  with every stable id backlinked to its definition. Invoked by the sdlc-task design loop and
8
8
  any session presenting a design/task/review to the owner — rendering is mechanical work and
9
9
  runs on Sonnet (sdlc-task skill, "Model tiering"). Input: a repo markdown doc path (+ optional emphasis
@@ -23,26 +23,26 @@ itself carries). Write the finished HTML to the output path the caller names, an
23
23
  return only a one-paragraph summary of what you rendered (sections, diagram count,
24
24
  any content you had to omit and why).
25
25
 
26
- ## Two formats: walkthrough for designs, Organic pages for the rest
27
-
28
- **A design doc made from the walkthrough template** (it has `## Part N —`
29
- and `### Step N —` lines) renders in the **walkthrough format**. The owner
30
- chose it on 7 October 2026, for designs explained to a new joiner with
31
- examples and pictures. Its reference file ships with this crew:
32
- `${CLAUDE_PLUGIN_ROOT}/agents/artifact-format/walkthrough.html`. Copy its
33
- CSS, script and structure, and swap in the doc's content:
34
-
35
- - One tab for each part, then a last "Reference" tab for the rest of the doc.
36
- - One step block for each step: the picture on the left, and the step text
37
- and an example card on the right. The example card holds the doc's
38
- `Example:` block, word for word.
39
- - One hand-built SVG for each part, built from the steps' `Picture:` lines.
40
- Each piece carries `data-step` with the step that adds it, so the picture
41
- builds up. Never use mermaid on the page.
42
- - The page works with all scripts removed. Each step block keeps its own
43
- copy of the picture, at that step's state. The reference file's comment
44
- states the contract.
45
- - The summary sits in a card at the top of Part 1.
26
+ ## Designs: figures only. Every other doc: an Organic page
27
+
28
+ **A design is a folder** with `design.yaml` and one file for each part.
29
+ `atlas design build <folder>` builds its page, with the rail, the bars, the
30
+ keys and both themes. You never build a design's page by hand.
31
+
32
+ For a design, the caller asks you for **one custom figure** for one part:
33
+ git lanes, a time line, a scorecard or a data diff. Write it as HTML inside a
34
+ fenced block with the info string `figure`, and return the block. The rules
35
+ are in `${CLAUDE_PLUGIN_ROOT}/skills/sdlc-task/design/README.md`, under
36
+ "Figures":
37
+
38
+ - Use the page tokens, such as `var(--accent)`, `var(--line)` and
39
+ `var(--card)`, so the figure works in the light and the dark theme. Never
40
+ write a colour with no token.
41
+ - A step has a title and one or two sentences. The reader sets the pace.
42
+ The figure prints as stills, and it works with its script removed.
43
+ - Meaning is never by colour alone.
44
+ - Close every tag. `atlas design check <folder>` flags a figure that does
45
+ not.
46
46
 
47
47
  **Every other doc** (an older design, a PRD, a review, a task explainer)
48
48
  renders in the Organic page format below.
package/door/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // The atlas command: install, upgrade, doctor, check, status, the tooling
3
3
  // writer, the kind drift report, the code-read resolver, the STE check, the
4
- // design check and the tests verbs (the link check and the test kit writer).
4
+ // design check and page builder, and the tests verbs (the link check and the
5
+ // test kit writer).
5
6
  //
6
7
  // Stage 1 of the target design retired `setup`, `change`, `helpers` and
7
8
  // `test onboarding`: code that guesses meaning retires (decided 28
@@ -10,7 +11,7 @@
10
11
  //
11
12
  // Nothing here writes to the work graph, and nothing here merges. A person
12
13
  // merges every file the tooling writer writes, because both steer agents.
13
- import { resolve, join, dirname } from 'node:path';
14
+ import { resolve, join, dirname, basename } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
16
17
  import { spawnSync } from 'node:child_process';
@@ -22,7 +23,8 @@ import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs'
22
23
  import { packagePackageVersion } from './lib/releases.mjs';
23
24
  import { install, upgrade, doctor } from './lib/install.mjs';
24
25
  import { checkPaths, LIMITS } from './lib/ste.mjs';
25
- import { checkDesignFile, MAX_STEP_SENTENCES } from './lib/design.mjs';
26
+ import { checkDesignFolder } from './lib/design.mjs';
27
+ import { writeDesignPage, readTracker } from './lib/design-build.mjs';
26
28
  import { loadPrivateTerms } from './lib/privacy.mjs';
27
29
  import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
28
30
  import { proofCommand as testsProof } from './lib/proof.mjs';
@@ -46,7 +48,11 @@ const HELP = `atlas — the door into a repo's Atlas files
46
48
  atlas ste <file or folder> … measure markdown and HTML against the STE limits
47
49
  --share also run the privacy filter, quotes included
48
50
  --terms <file> private terms; default ~/.config/atlas/private-terms.txt
49
- atlas design <file> … check a walkthrough design doc: summary, parts, an example and a picture in each step
51
+ atlas design check <folder> … check a design folder: each needed part, no state line, every code in the Key
52
+ atlas design build <folder> build the design's page from its folder
53
+ --tracker <file> the tracker data, as JSON, from the lead
54
+ --out <file> the page; default docs/artifacts/<folder name>.html
55
+ --draft build even when the check has findings, and mark the page a draft
50
56
 
51
57
  atlas tests check --root <area> run the link check on the tests of an area
52
58
  --halves <list> static, lint, run; default all three
@@ -82,7 +88,7 @@ export const FLAGS = Object.freeze({
82
88
  'kind-drift': { root: 'optional', 'record-kind': 'value', registry: 'value', repo: 'value' },
83
89
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
84
90
  ste: { share: 'switch', terms: 'value' },
85
- design: {},
91
+ design: { tracker: 'value', out: 'value', draft: 'switch' },
86
92
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value' },
87
93
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
88
94
  upgrade: { version: 'optional', yes: 'switch' },
@@ -221,15 +227,34 @@ function steCommand(chosen) {
221
227
  }
222
228
 
223
229
  function designCommand(chosen) {
224
- const paths = chosen._.slice(1);
225
- if (!paths.length) throw new Error('give a design doc: atlas design <file>');
230
+ const what = chosen._[1];
231
+ const folders = chosen._.slice(2);
232
+ if (what !== 'check' && what !== 'build') throw new Error('use atlas design check <folder> or atlas design build <folder>');
233
+ if (!folders.length) throw new Error(`give a design folder: atlas design ${what} <folder>`);
234
+ if (what === 'check' && (chosen.tracker !== undefined || chosen.out !== undefined || chosen.draft)) throw new Error('--tracker, --out and --draft work only with atlas design build.');
235
+ if (what === 'build') {
236
+ if (folders.length > 1) throw new Error('atlas design build takes one folder.');
237
+ const folder = resolve(folders[0]);
238
+ const out = resolve(chosen.out ?? join('docs', 'artifacts', `${basename(folder)}.html`));
239
+ const tracker = chosen.tracker !== undefined ? readTracker(resolve(chosen.tracker)) : null;
240
+ const result = writeDesignPage(folder, out, { tracker, draft: Boolean(chosen.draft) });
241
+ if (!result.html) {
242
+ for (const finding of result.findings) line(`${join(folders[0], finding.file)}:${finding.line} ${finding.rule} ${finding.why}`);
243
+ line('');
244
+ line(`No page written: the check has ${result.findings.length} finding(s). Fix them, or build a draft with --draft.`);
245
+ return 1;
246
+ }
247
+ line(`Wrote ${out}: ${result.parts} parts, ${result.groups} groups, ${result.figures} figure(s).${tracker ? '' : ' No tracker data: the tracker parts say so.'}`);
248
+ if (result.findings.length) line(`A draft: the check has ${result.findings.length} finding(s). Run atlas design check ${folders[0]}.`);
249
+ return 0;
250
+ }
226
251
  let count = 0;
227
- for (const path of paths) {
228
- for (const finding of checkDesignFile(resolve(path))) { count += 1; line(`${path}:${finding.line} ${finding.rule} ${finding.why}`); }
252
+ for (const folder of folders) {
253
+ for (const finding of checkDesignFolder(resolve(folder))) { count += 1; line(`${join(folder, finding.file)}:${finding.line} ${finding.rule} ${finding.why}`); }
229
254
  }
230
255
  line('');
231
- line(`${paths.length} design doc(s) read; ${count} finding(s). Each step needs an Example block and a picture, and at most ${MAX_STEP_SENTENCES} sentences before its example.`);
232
- line(count ? 'Fix the doc by hand. This check never rewrites.' : 'GREEN');
256
+ line(`${folders.length} design(s) read; ${count} finding(s). The parts each kind needs are in skills/sdlc-task/design/parts.yaml.`);
257
+ line(count ? 'Fix the design by hand. This check never rewrites.' : 'GREEN');
233
258
  return count ? 1 : 0;
234
259
  }
235
260
 
@@ -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
+ }