@arjunkhera/atlas 0.4.47 → 0.4.48
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/door/cli.mjs +28 -0
- package/door/lib/design-build.mjs +3 -2
- package/door/lib/page.mjs +242 -0
- package/door/lib/ste.mjs +7 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +16 -3
- package/view/page.html +1 -0
package/door/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
|
|
|
24
24
|
import { checkPaths, LIMITS } from './lib/ste.mjs';
|
|
25
25
|
import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
|
|
26
26
|
import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
27
|
+
import { writePage } from './lib/page.mjs';
|
|
27
28
|
import { writeViewPage, viewCapabilities } from './lib/view.mjs';
|
|
28
29
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
29
30
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
@@ -60,6 +61,7 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
60
61
|
|
|
61
62
|
atlas ste <file or folder> … measure markdown and HTML against the STE limits
|
|
62
63
|
--share also run the privacy filter, quotes included
|
|
64
|
+
an HTML page also needs a woodcut figure and only Atlas theme colours
|
|
63
65
|
--terms <file> private terms; default ~/.config/atlas/private-terms.txt
|
|
64
66
|
atlas design check <folder> … check a design folder: each needed part, no state line, every code in the Key
|
|
65
67
|
atlas design folder print where this repo keeps its designs: design.folder in atlas.yaml, or docs/design-docs
|
|
@@ -69,6 +71,10 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
69
71
|
--out <file> the page; default docs/artifacts/<folder name>.html
|
|
70
72
|
--draft build even when the check has findings, and mark the page a draft
|
|
71
73
|
|
|
74
|
+
atlas page build <file or folder> build an explainer page from markdown: woodcut figures, the Atlas theme, light and dark
|
|
75
|
+
--out <file> the page; default <file or folder>.html beside it
|
|
76
|
+
--title <text> the page title; default the title of the first part
|
|
77
|
+
|
|
72
78
|
atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
|
|
73
79
|
--name <name> the product's name on the page; default the id with a capital
|
|
74
80
|
--out <file> the page; default docs/artifacts/<id>-view.html
|
|
@@ -211,6 +217,7 @@ export const FLAGS = Object.freeze({
|
|
|
211
217
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value', json: 'switch' },
|
|
212
218
|
ste: { share: 'switch', terms: 'value' },
|
|
213
219
|
design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
|
|
220
|
+
page: { out: 'value', title: 'value' },
|
|
214
221
|
view: { product: 'value', name: 'value', out: 'value' },
|
|
215
222
|
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
|
|
216
223
|
jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
|
|
@@ -391,6 +398,26 @@ function designCommand(chosen) {
|
|
|
391
398
|
return count ? 1 : 0;
|
|
392
399
|
}
|
|
393
400
|
|
|
401
|
+
function pageCommand(chosen) {
|
|
402
|
+
const sources = chosen._.slice(2);
|
|
403
|
+
if (chosen._[1] !== 'build') throw new Error('use atlas page build <file or folder>');
|
|
404
|
+
if (sources.length !== 1) throw new Error('give one markdown file or one folder: atlas page build <file or folder>');
|
|
405
|
+
const source = resolve(sources[0]);
|
|
406
|
+
const out = resolve(chosen.out ?? `${source.replace(/\.md$/, '').replace(/\/+$/, '')}.html`);
|
|
407
|
+
const result = writePage(source, out, { title: chosen.title ?? null });
|
|
408
|
+
if (!result.html) {
|
|
409
|
+
for (const finding of result.findings) line(`${finding.file}:${finding.line} page ${finding.why}`);
|
|
410
|
+
line('');
|
|
411
|
+
line(`No page written: ${result.findings.length} finding(s). Fix the markdown by hand.`);
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
line(`Wrote ${out}: ${result.parts} part(s), ${result.figures} woodcut figure(s), ${result.walks} that walk.`);
|
|
415
|
+
const findings = checkPaths([out]).flatMap((one) => one.findings);
|
|
416
|
+
for (const finding of findings) line(`${out}:${finding.line} ${finding.rule} ${finding.why}`);
|
|
417
|
+
line(findings.length ? `atlas ste: ${findings.length} finding(s) on the page. Fix the markdown by hand and build again.` : 'atlas ste: GREEN. Publish the page.');
|
|
418
|
+
return findings.length ? 1 : 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
394
421
|
function viewCommand(chosen) {
|
|
395
422
|
if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
|
|
396
423
|
const product = chosen.product;
|
|
@@ -481,6 +508,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
481
508
|
case 'check': return checkCommand(chosen);
|
|
482
509
|
case 'ste': return steCommand(chosen);
|
|
483
510
|
case 'design': return designCommand(chosen);
|
|
511
|
+
case 'page': return pageCommand(chosen);
|
|
484
512
|
case 'view': return viewCommand(chosen);
|
|
485
513
|
case 'tests': return testsCommand(chosen);
|
|
486
514
|
case 'jobs': return jobsVerb(chosen);
|
|
@@ -40,7 +40,7 @@ export const TOKENS = Object.freeze({
|
|
|
40
40
|
|
|
41
41
|
const tokenBlock = (index) => Object.entries(TOKENS).map(([name, pair]) => `--${name}:${pair[index]}`).join(';');
|
|
42
42
|
|
|
43
|
-
const CSS = `
|
|
43
|
+
export const CSS = `
|
|
44
44
|
:root{${tokenBlock(0)};color-scheme:light;
|
|
45
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
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;
|
|
@@ -138,7 +138,7 @@ kbd{border:1px solid var(--line-strong);border-radius:4px;padding:0 5px;font-fam
|
|
|
138
138
|
@media print{body{overflow:visible}.app{display:block;height:auto}.rail,.bar.foot{display:none}#scroll{overflow:visible}.part[hidden]{display:flex}}
|
|
139
139
|
`;
|
|
140
140
|
|
|
141
|
-
const SCRIPT = `
|
|
141
|
+
export const SCRIPT = `
|
|
142
142
|
(function () {
|
|
143
143
|
var data = JSON.parse(document.getElementById('design-data').textContent);
|
|
144
144
|
var flat = [];
|
|
@@ -356,6 +356,7 @@ export function buildDesign(folder, { tracker = null, draft = false, registry =
|
|
|
356
356
|
<head>
|
|
357
357
|
<meta charset="utf-8">
|
|
358
358
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
359
|
+
<meta name="generator" content="atlas design build">
|
|
359
360
|
<title>${esc(title)}</title>
|
|
360
361
|
<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
362
|
<style>${CSS}</style>
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The explainer page builder, and the page rules of `atlas ste`.
|
|
2
|
+
//
|
|
3
|
+
// An explainer tells the owner how a technical flow works or where it
|
|
4
|
+
// stands. Owner, 30 October 2026: "I would expect things to be explained
|
|
5
|
+
// visually, the results are very disappointing, there is no theme in
|
|
6
|
+
// pages" (lesson 8678a27b, item 99ccaa71). So one builder makes every
|
|
7
|
+
// explainer, and the STE check refuses a page that skips it.
|
|
8
|
+
//
|
|
9
|
+
// `atlas page build <file or folder>` reads markdown: one file is one part,
|
|
10
|
+
// a folder is one part for each .md file, in name order. The parts use the
|
|
11
|
+
// blocks of the design renderer (markdown.mjs), with woodcut figures. The
|
|
12
|
+
// page is the design page frame: a left rail, Back and Next, the arrow keys,
|
|
13
|
+
// and a light and a dark theme from the design token set. The build refuses
|
|
14
|
+
// a source with no woodcut figure, a figure woodcut cannot draw, or no
|
|
15
|
+
// figure that walks: a scenario with two or more titled steps.
|
|
16
|
+
//
|
|
17
|
+
// The page rules read each HTML page that `atlas ste` reads:
|
|
18
|
+
// page-figure the page draws no woodcut figure. A design page (its own
|
|
19
|
+
// check gates its pictures) and a project view page (a data
|
|
20
|
+
// view, not an explainer) are exempt, by their generator line.
|
|
21
|
+
// page-theme a colour that is in no Atlas theme, or no dark theme.
|
|
22
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { CSS, SCRIPT, TOKENS, WOODCUT } from './design-build.mjs';
|
|
26
|
+
import { frontMatter, woodcutFault, woodcutWalks } from './design.mjs';
|
|
27
|
+
import { render, esc, slug, fenceOpens, fenceCloses, WOODCUT_TYPES } from './markdown.mjs';
|
|
28
|
+
|
|
29
|
+
export const GENERATOR = 'atlas page build';
|
|
30
|
+
// Builders whose pages answer to their own check, not to page-figure.
|
|
31
|
+
export const FIGURE_EXEMPT = Object.freeze(['atlas design build', 'atlas view build']);
|
|
32
|
+
|
|
33
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------- the build
|
|
36
|
+
|
|
37
|
+
// The woodcut fences of one markdown body, with their line numbers.
|
|
38
|
+
function woodcutFences(body, firstLine) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let fence = null;
|
|
41
|
+
body.replace(/\r\n?/g, '\n').split('\n').forEach((text, index) => {
|
|
42
|
+
const line = firstLine + index;
|
|
43
|
+
if (fence) {
|
|
44
|
+
if (fenceCloses(text, fence.mark)) { if (fence.woodcut) out.push(fence); fence = null; } else fence.lines.push(text);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const mark = fenceOpens(text);
|
|
48
|
+
if (!mark) return;
|
|
49
|
+
const [info, type] = text.trim().slice(mark.length).trim().split(/\s+/);
|
|
50
|
+
fence = { mark, woodcut: info === 'woodcut', type: type ?? null, line, lines: [] };
|
|
51
|
+
});
|
|
52
|
+
if (fence?.woodcut) out.push({ ...fence, open: true });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readSource(source) {
|
|
57
|
+
const root = resolve(source);
|
|
58
|
+
if (!existsSync(root)) throw new Error(`${source} does not exist`);
|
|
59
|
+
const folder = statSync(root).isDirectory();
|
|
60
|
+
const names = folder ? readdirSync(root).filter((name) => name.endsWith('.md')).sort() : [basename(root)];
|
|
61
|
+
if (!names.length) throw new Error(`${source} holds no .md file`);
|
|
62
|
+
const base = folder ? root : dirname(root);
|
|
63
|
+
return {
|
|
64
|
+
root,
|
|
65
|
+
name: basename(root).replace(/\.md$/, ''),
|
|
66
|
+
parts: names.map((file) => {
|
|
67
|
+
const { meta, body, bodyLine } = frontMatter(readFileSync(join(base, file), 'utf8'));
|
|
68
|
+
return { file, id: slug(file.replace(/\.md$/, '')) || 'part', title: typeof meta.title === 'string' ? meta.title : null, body, bodyLine };
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The findings that stop a build. Each one names a file and a line.
|
|
74
|
+
export function checkSource(source) {
|
|
75
|
+
const findings = [];
|
|
76
|
+
let figures = 0;
|
|
77
|
+
let walks = 0;
|
|
78
|
+
for (const part of source.parts) {
|
|
79
|
+
for (const fence of woodcutFences(part.body, part.bodyLine)) {
|
|
80
|
+
const at = { file: part.file, line: fence.line };
|
|
81
|
+
if (fence.open) { findings.push({ ...at, why: 'the woodcut block does not close' }); continue; }
|
|
82
|
+
if (!WOODCUT_TYPES[fence.type]) { findings.push({ ...at, why: `woodcut has no figure "${fence.type ?? ''}"; use ${Object.keys(WOODCUT_TYPES).join(', ')}` }); continue; }
|
|
83
|
+
let data;
|
|
84
|
+
try { data = JSON.parse(fence.lines.join('\n')); } catch (error) { findings.push({ ...at, why: `the woodcut data is not JSON: ${error.message}` }); continue; }
|
|
85
|
+
const fault = woodcutFault(fence.type, data);
|
|
86
|
+
if (fault) { findings.push({ ...at, why: fault }); continue; }
|
|
87
|
+
figures += 1;
|
|
88
|
+
if (woodcutWalks(data)) walks += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const first = source.parts[0].file;
|
|
92
|
+
if (!figures && !findings.length) findings.push({ file: first, line: 1, why: 'the page has no woodcut figure. Explain the flow with a `woodcut wc-swimlane` (or another woodcut type) block, not prose alone' });
|
|
93
|
+
else if (!walks && !findings.length) findings.push({ file: first, line: 1, why: 'no figure walks the flow. Give one woodcut figure a scenario with two or more steps, each with a title' });
|
|
94
|
+
return { findings, figures, walks };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildPage(path, { title = null } = {}) {
|
|
98
|
+
const source = readSource(path);
|
|
99
|
+
const { findings, figures, walks } = checkSource(source);
|
|
100
|
+
if (findings.length) return { html: null, findings, parts: source.parts.length, figures, walks };
|
|
101
|
+
const ids = new Set();
|
|
102
|
+
const byFile = new Map();
|
|
103
|
+
for (const part of source.parts) {
|
|
104
|
+
let id = part.id;
|
|
105
|
+
for (let n = 2; ids.has(id); n += 1) id = `${part.id}-${n}`;
|
|
106
|
+
ids.add(id);
|
|
107
|
+
part.id = id;
|
|
108
|
+
byFile.set(part.file, id);
|
|
109
|
+
}
|
|
110
|
+
const drawn = [];
|
|
111
|
+
const items = [];
|
|
112
|
+
const sections = source.parts.map((part) => {
|
|
113
|
+
const ctx = { file: part.file, page: part.id, figures: drawn, partOf: (href) => byFile.get(href.split('#')[0]) ?? null };
|
|
114
|
+
const out = render(part.body, ctx);
|
|
115
|
+
const heading = part.title ?? out.title ?? part.file.replace(/\.md$/, '');
|
|
116
|
+
items.push({ id: part.id, name: heading, ro: false });
|
|
117
|
+
return `<section class="part" data-part="${part.id}" id="part-${part.id}" hidden><h2>${esc(heading)}</h2>\n${out.html}\n</section>`;
|
|
118
|
+
});
|
|
119
|
+
const name = title ?? items[0].name;
|
|
120
|
+
const data = { groups: [{ id: 'pages', items }] };
|
|
121
|
+
const rail = `<div class="group"><button type="button" class="ghead" aria-expanded="true"><span class="chev">▾</span><span>Pages</span><span class="n">${items.length}</span></button>${items.map((item, index) => `<button type="button" class="item" data-go="${item.id}"><span class="n">${index + 1}</span><span>${esc(item.name)}</span></button>`).join('')}</div>`;
|
|
122
|
+
const mini = items.map((item, index) => `<button type="button" class="mini-item" data-go="${item.id}" title="${esc(item.name)}">${index + 1}</button>`).join('');
|
|
123
|
+
const html = `<!doctype html>
|
|
124
|
+
<html lang="en">
|
|
125
|
+
<head>
|
|
126
|
+
<meta charset="utf-8">
|
|
127
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
128
|
+
<meta name="generator" content="${GENERATOR}">
|
|
129
|
+
<title>${esc(name)}</title>
|
|
130
|
+
<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">
|
|
131
|
+
<style>${CSS}</style>
|
|
132
|
+
<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>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<div class="app">
|
|
136
|
+
<aside class="rail" id="rail" aria-label="Pages">
|
|
137
|
+
<div class="full">
|
|
138
|
+
<div class="railhead"><div class="kv"><span class="lbl">Explainer</span><b>${esc(name)}</b></div><button type="button" class="sq" data-rail aria-label="Fold the side rail" title="Fold">«</button></div>
|
|
139
|
+
<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>
|
|
140
|
+
${rail}
|
|
141
|
+
</div>
|
|
142
|
+
<div class="strip"><button type="button" class="sq" data-rail aria-label="Open the side rail" title="Open" style="margin-bottom:10px">»</button>${mini}</div>
|
|
143
|
+
</aside>
|
|
144
|
+
<main>
|
|
145
|
+
<div class="bar top"><nav class="inner"><b style="font-weight:500">${esc(name)}</b><span class="tag">EXPLAINER</span><button type="button" class="pill" id="theme" aria-label="Switch light or dark">☾ Dark</button></nav></div>
|
|
146
|
+
<div id="scroll"><div class="inner">
|
|
147
|
+
${name === items[0].name ? '' : `<header><h1>${esc(name)}</h1></header>\n`}<div class="pos" id="pos"></div>
|
|
148
|
+
${sections.join('\n')}
|
|
149
|
+
</div></div>
|
|
150
|
+
<div class="bar foot"><div class="inner"><button type="button" class="nav" id="prev" aria-label="Previous page"></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 page"></button></div></div>
|
|
151
|
+
</main>
|
|
152
|
+
</div>
|
|
153
|
+
<script type="application/json" id="design-data">${JSON.stringify(data).replace(/<\//g, '<\\/')}</script>
|
|
154
|
+
<script type="module" src="${WOODCUT}"></script>
|
|
155
|
+
<script>${SCRIPT}</script>
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
158
|
+
`;
|
|
159
|
+
return { html, findings: [], parts: items.length, figures, walks };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function writePage(path, out, options = {}) {
|
|
163
|
+
const result = buildPage(path, options);
|
|
164
|
+
if (!result.html) return result;
|
|
165
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
166
|
+
writeFileSync(out, result.html);
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------- the page rules
|
|
171
|
+
|
|
172
|
+
// One spelling for each colour: lower case, no spaces, #abc as #aabbcc.
|
|
173
|
+
export function normalColour(value) {
|
|
174
|
+
const text = String(value).toLowerCase().replace(/\s+/g, '');
|
|
175
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/.exec(text);
|
|
176
|
+
if (short) return `#${short.slice(1).filter(Boolean).map((one) => one + one).join('')}`;
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const COLOUR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/g;
|
|
181
|
+
// Named colours that a hand-made page uses most. A name is in no theme.
|
|
182
|
+
const NAMED = /(?<![\w-])(red|green|blue|black|white|gray|grey|orange|yellow|purple|pink|brown|navy|teal|silver|gold|maroon|olive|lime|aqua|cyan|magenta|crimson|coral|salmon|tomato|indigo|violet|beige|ivory|khaki|lavender|tan|wheat)(?![\w-])/gi;
|
|
183
|
+
// Black and white with an alpha only darken or lighten: a shadow, a scrim.
|
|
184
|
+
const NEUTRAL = /^rgba\((0,0,0|255,255,255),[\d.]+\)$/;
|
|
185
|
+
|
|
186
|
+
let palette = null;
|
|
187
|
+
// The colours of the Atlas themes: the design token set (design and
|
|
188
|
+
// explainer pages), the crew's page format, and the project view page.
|
|
189
|
+
export function themePalette() {
|
|
190
|
+
if (palette) return palette;
|
|
191
|
+
const set = new Set(Object.values(TOKENS).flat().map(normalColour));
|
|
192
|
+
for (const file of [join(PACKAGE_ROOT, 'agents', 'artifact-format', 'system.css'), join(PACKAGE_ROOT, 'view', 'page.html')]) {
|
|
193
|
+
if (!existsSync(file)) continue;
|
|
194
|
+
for (const match of readFileSync(file, 'utf8').matchAll(COLOUR)) set.add(normalColour(match[0]));
|
|
195
|
+
}
|
|
196
|
+
palette = set;
|
|
197
|
+
return set;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lineAt = (text, offset) => text.slice(0, offset).split('\n').length;
|
|
201
|
+
|
|
202
|
+
// The places a page paints a colour: the values of its style blocks, its
|
|
203
|
+
// style attributes, and its colour attributes. Each comes with its offset.
|
|
204
|
+
function paintSpots(text) {
|
|
205
|
+
const spots = [];
|
|
206
|
+
for (const block of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
207
|
+
const start = block.index + block[0].indexOf(block[1]);
|
|
208
|
+
for (const value of block[1].matchAll(/:([^;{}]+)/g)) spots.push({ at: start + value.index + 1, text: value[1] });
|
|
209
|
+
}
|
|
210
|
+
for (const attr of text.matchAll(/\s(style|fill|stroke|color|bgcolor|stop-color|flood-color)\s*=\s*("[^"]*"|'[^']*')/gi)) {
|
|
211
|
+
spots.push({ at: attr.index + attr[0].indexOf(attr[2]) + 1, text: attr[2].slice(1, -1) });
|
|
212
|
+
}
|
|
213
|
+
return spots;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const generatorOf = (text) => /<meta\s+name=["']generator["']\s+content=["']([^"']*)["']/i.exec(text)?.[1] ?? null;
|
|
217
|
+
const DARK = /\[data-theme=["']?dark["']?\]|prefers-color-scheme:\s*dark/i;
|
|
218
|
+
|
|
219
|
+
export function pageFindings(text) {
|
|
220
|
+
const findings = [];
|
|
221
|
+
const generator = generatorOf(text);
|
|
222
|
+
const types = Object.keys(WOODCUT_TYPES).join('|');
|
|
223
|
+
const element = new RegExp(`<(${types})\\b`, 'i').exec(text);
|
|
224
|
+
const loader = /<script\b[^>]*\bsrc=["'][^"']*woodcut[^"']*["']/i.test(text);
|
|
225
|
+
if (!FIGURE_EXEMPT.includes(generator)) {
|
|
226
|
+
if (!element) findings.push({ line: 1, rule: 'page-figure', why: 'the page draws no woodcut figure. Build an explainer with atlas page build, and walk the flow in a woodcut figure' });
|
|
227
|
+
else if (!loader) findings.push({ line: lineAt(text, element.index), rule: 'page-figure', why: `the page has a <${element[1]}> figure but loads no woodcut script, so it draws nothing` });
|
|
228
|
+
}
|
|
229
|
+
const spots = paintSpots(text);
|
|
230
|
+
if (spots.length && !DARK.test(text)) findings.push({ line: 1, rule: 'page-theme', why: 'the page has no dark theme. Build it with atlas page build, which carries both themes' });
|
|
231
|
+
const known = themePalette();
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
for (const spot of spots) {
|
|
234
|
+
for (const match of [...spot.text.matchAll(COLOUR), ...spot.text.matchAll(NAMED)]) {
|
|
235
|
+
const colour = normalColour(match[0]);
|
|
236
|
+
if (known.has(colour) || NEUTRAL.test(colour) || seen.has(colour)) continue;
|
|
237
|
+
seen.add(colour);
|
|
238
|
+
findings.push({ line: lineAt(text, spot.at + match.index), rule: 'page-theme', why: `the colour ${match[0]} is in no Atlas theme. Paint with a theme token, such as var(--accent)` });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return findings;
|
|
242
|
+
}
|
package/door/lib/ste.mjs
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
//
|
|
10
10
|
// `--share` adds the privacy filter, with no exemption for quotes: a quoted
|
|
11
11
|
// email address still leaves with the page.
|
|
12
|
+
//
|
|
13
|
+
// An HTML page also meets the page rules of page.mjs: it draws a woodcut
|
|
14
|
+
// figure, and it paints only with the colours of an Atlas theme.
|
|
12
15
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
16
|
import { dirname, extname, join, relative } from 'node:path';
|
|
14
17
|
import { fileURLToPath } from 'node:url';
|
|
15
18
|
import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
|
|
16
19
|
import { fenceOpens, fenceCloses } from './markdown.mjs';
|
|
20
|
+
import { pageFindings } from './page.mjs';
|
|
17
21
|
|
|
18
22
|
export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
|
|
19
23
|
|
|
@@ -181,8 +185,9 @@ export function pagesUnder(path) {
|
|
|
181
185
|
return out;
|
|
182
186
|
}
|
|
183
187
|
|
|
184
|
-
export function checkText(text, { html = false, share = false, terms = [] } = {}) {
|
|
188
|
+
export function checkText(text, { html = false, page = false, share = false, terms = [] } = {}) {
|
|
185
189
|
const findings = measure(html ? htmlUnits(text) : markdownUnits(text));
|
|
190
|
+
if (page) findings.push(...pageFindings(text));
|
|
186
191
|
if (share) findings.push(...privacyFindings(text, { terms }).map((finding) => ({ ...finding, rule: `privacy:${finding.rule}` })));
|
|
187
192
|
return findings.sort((a, b) => a.line - b.line);
|
|
188
193
|
}
|
|
@@ -193,7 +198,7 @@ export function checkPaths(paths, { share = false, terms = null, base = process.
|
|
|
193
198
|
for (const path of paths) {
|
|
194
199
|
for (const file of pagesUnder(path)) {
|
|
195
200
|
const html = /\.html?$/i.test(file);
|
|
196
|
-
const findings = checkText(readFileSync(file, 'utf8'), { html, share, terms: own });
|
|
201
|
+
const findings = checkText(readFileSync(file, 'utf8'), { html, page: html, share, terms: own });
|
|
197
202
|
results.push({ file: relative(base, file) || file, findings });
|
|
198
203
|
}
|
|
199
204
|
}
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -33,9 +33,20 @@ Follow all eleven in every reply, page and question.
|
|
|
33
33
|
9. Link every explainer page from the document it explains.
|
|
34
34
|
10. Pick the form that is easiest to understand. Put a diagram before
|
|
35
35
|
prose, and a page before long chat text. Where motion explains a
|
|
36
|
-
sequence, animate the diagram in the page.
|
|
36
|
+
sequence, animate the diagram in the page. Explain each technical
|
|
37
|
+
flow on a page from `atlas page build <file or folder>`:
|
|
38
|
+
- A flow is how a thing works, the steps of a process, or where a
|
|
39
|
+
product stands.
|
|
40
|
+
- The page holds markdown and a woodcut figure that the owner steps
|
|
41
|
+
through, such as a `woodcut wc-swimlane` block.
|
|
42
|
+
- Never make an explainer page by hand. Never explain a flow in chat
|
|
43
|
+
text alone.
|
|
44
|
+
- The owner, 30 October 2026: "I would expect things to be explained
|
|
45
|
+
visually".
|
|
37
46
|
11. Write in ASD-STE100. Run `atlas ste` on each page before you publish
|
|
38
|
-
it, and fix what it flags by hand. Quoted words are exempt.
|
|
47
|
+
it, and fix what it flags by hand. Quoted words are exempt. On an
|
|
48
|
+
HTML page, it also flags no woodcut figure, no dark theme, and a
|
|
49
|
+
colour in no Atlas theme.
|
|
39
50
|
|
|
40
51
|
Before a page or an issue goes to anyone other than the owner, run
|
|
41
52
|
`atlas ste --share` on it. That adds the privacy filter. It reads the
|
|
@@ -55,7 +66,8 @@ say that when you hand over a page.
|
|
|
55
66
|
| Where a capability stands in the code | The `atlas:capability-reader` crew |
|
|
56
67
|
| Review a design or a diff | The three reviewer crews, as sdlc-task says |
|
|
57
68
|
| Prove a pull request against its definition of done | The `atlas:verifier` crew |
|
|
58
|
-
|
|
|
69
|
+
| Explain a technical flow or a status to the owner | The lead, with `atlas page build <file or folder>` (talk rule 10) |
|
|
70
|
+
| Render a design, a PRD or a review page for the owner | The `atlas:artifact-renderer` crew |
|
|
59
71
|
| Run the fresh-agent test at the end of onboarding: pick, run and grade three tasks | The `atlas:fresh-agent-test` skill, which sends the `atlas:judge` crew |
|
|
60
72
|
| Onboard an area to tests, review scenario words, write or convert a test, sort a red test, plant faults | The `atlas:tests` skill |
|
|
61
73
|
|
|
@@ -110,6 +122,7 @@ set. A test keeps the two lists equal.
|
|
|
110
122
|
| `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
|
|
111
123
|
| `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `changed`, `before` and `after` run the test checks of the gate: changed tests, new cases fail on old code, old cases pass on new code. `sweep` and `issues` run the daily sweep |
|
|
112
124
|
| `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
|
|
125
|
+
| `atlas page` | `build <file or folder>` makes an explainer page, as talk rule 10 says. It refuses markdown with no woodcut figure that walks. Run `atlas ste` on the page, then publish it |
|
|
113
126
|
| `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
|
|
114
127
|
| `atlas jobs` | The `atlas-jobs` workflow calls `check`, then `file`, once a day. A session calls it only to see what the watcher would file: `check`, then `file --dry-run` |
|
|
115
128
|
| `atlas upkeep` | `check` before you ask for a merge. It reads the docs index, dead doc links, the code each doc names and stale pages. In a pull request it also reads the tracker item, the changed designs, and STE on the changed lines. The daily upkeep run calls it on the main branch. `replay` before the doc-sources rule changes how it gates. `procedures` runs the procedure steps tagged `atlas-run`; CI runs it with `--base` on each pull request |
|
package/view/page.html
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="generator" content="atlas view build">
|
|
6
7
|
<title>@@TITLE@@</title>
|
|
7
8
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:opsz,wght@8..60,600&display=swap">
|
|
8
9
|
<style>
|