@arjunkhera/atlas 0.3.8 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/artifact-renderer.md +22 -22
- package/door/cli.mjs +36 -11
- package/door/lib/design-build.mjs +409 -0
- package/door/lib/design.mjs +199 -118
- package/door/lib/markdown.mjs +160 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +1 -1
- package/skills/sdlc-task/SKILL.md +33 -24
- package/skills/sdlc-task/design/README.md +187 -0
- package/skills/sdlc-task/design/parts/actors.md +26 -0
- package/skills/sdlc-task/design/parts/alternatives.md +24 -0
- package/skills/sdlc-task/design/parts/build.md +23 -0
- package/skills/sdlc-task/design/parts/calls.md +25 -0
- package/skills/sdlc-task/design/parts/change.md +27 -0
- package/skills/sdlc-task/design/parts/data.md +22 -0
- package/skills/sdlc-task/design/parts/done.md +23 -0
- package/skills/sdlc-task/design/parts/edges.md +24 -0
- package/skills/sdlc-task/design/parts/goals.md +27 -0
- package/skills/sdlc-task/design/parts/key.md +25 -0
- package/skills/sdlc-task/design/parts/migration.md +22 -0
- package/skills/sdlc-task/design/parts/order.md +24 -0
- package/skills/sdlc-task/design/parts/problem.md +22 -0
- package/skills/sdlc-task/design/parts/proof.md +24 -0
- package/skills/sdlc-task/design/parts/proposal.md +24 -0
- package/skills/sdlc-task/design/parts/records.md +24 -0
- package/skills/sdlc-task/design/parts/repos.md +25 -0
- package/skills/sdlc-task/design/parts/risks.md +24 -0
- package/skills/sdlc-task/design/parts/rollout.md +24 -0
- package/skills/sdlc-task/design/parts/routes.md +24 -0
- package/skills/sdlc-task/design/parts/scorecard.md +25 -0
- package/skills/sdlc-task/design/parts/security.md +22 -0
- package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
- package/skills/sdlc-task/design/parts/states.md +25 -0
- package/skills/sdlc-task/design/parts/stories.md +26 -0
- package/skills/sdlc-task/design/parts/summary.md +33 -0
- package/skills/sdlc-task/design/parts/why.md +22 -0
- package/skills/sdlc-task/design/parts/words.md +29 -0
- package/skills/sdlc-task/design/parts/yardstick.md +25 -0
- package/skills/sdlc-task/design/parts.yaml +306 -0
- package/skills/sdlc-task/lifecycle.yaml +2 -2
- package/agents/artifact-format/walkthrough.html +0 -706
- package/skills/sdlc-task/templates/design-doc.md +0 -126
|
@@ -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
|
|
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
|
-
##
|
|
27
|
-
|
|
28
|
-
**A design
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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 {
|
|
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 <
|
|
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
|
|
225
|
-
|
|
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
|
|
228
|
-
for (const finding of
|
|
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(`${
|
|
232
|
-
line(count ? 'Fix the
|
|
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
|
+
}
|