@arjunkhera/atlas 0.3.22 → 0.3.25

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.22",
3
+ "version": "0.3.25",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -47,6 +47,8 @@ are in `${CLAUDE_PLUGIN_ROOT}/skills/sdlc-task/design/README.md`, under
47
47
  - A step has a title and one or two sentences. The reader sets the pace.
48
48
  The figure prints as stills, and it works with its script removed.
49
49
  - Meaning is never by colour alone.
50
+ - A figure for a story walks the story: it draws each step in order, so
51
+ the reader sees the flow move. The check fails a story with no such figure.
50
52
  - Close every tag. `atlas design check <folder>` flags a figure that does
51
53
  not.
52
54
 
package/door/cli.mjs CHANGED
@@ -25,6 +25,7 @@ import { install, upgrade, doctor } from './lib/install.mjs';
25
25
  import { checkPaths, LIMITS } from './lib/ste.mjs';
26
26
  import { checkDesignFolder, designFolderOf, repoRootFrom } from './lib/design.mjs';
27
27
  import { writeDesignPage, readTracker } from './lib/design-build.mjs';
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';
30
31
  import { proofCommand as testsProof } from './lib/proof.mjs';
@@ -59,6 +60,10 @@ const HELP = `atlas — the door into a repo's Atlas files
59
60
  --out <file> the page; default docs/artifacts/<folder name>.html
60
61
  --draft build even when the check has findings, and mark the page a draft
61
62
 
63
+ atlas view build --product <id> build the project view page of one product: the four views, read live from the tracker
64
+ --name <name> the product's name on the page; default the id with a capital
65
+ --out <file> the page; default docs/artifacts/<id>-view.html
66
+
62
67
  atlas tests check --root <area> run the link check on the tests of an area
63
68
  --halves <list> static, lint, run; default all three
64
69
  --evidence <dir> where the evidence files are; default <area>/test/evidence
@@ -120,6 +125,7 @@ export const FLAGS = Object.freeze({
120
125
  check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
121
126
  ste: { share: 'switch', terms: 'value' },
122
127
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
128
+ view: { product: 'value', name: 'value', out: 'value' },
123
129
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
124
130
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
125
131
  upgrade: { version: 'optional', yes: 'switch' },
@@ -296,6 +302,16 @@ function designCommand(chosen) {
296
302
  return count ? 1 : 0;
297
303
  }
298
304
 
305
+ function viewCommand(chosen) {
306
+ if (chosen._[1] !== 'build' || chosen._.length > 2) throw new Error('use atlas view build --product <id>');
307
+ const product = chosen.product;
308
+ const out = resolve(chosen.out ?? join('docs', 'artifacts', `${product}-view.html`));
309
+ const page = writeViewPage(out, { packageRoot: PACKAGE_ROOT, product, name: chosen.name, version: ATLAS_VERSION });
310
+ line(`Wrote ${out}: the project view of ${page.name} (product ${page.product}). It holds no records; it reads them at open.`);
311
+ line(`Publish it with this capability, and no other: ${JSON.stringify(viewCapabilities())}`);
312
+ return 0;
313
+ }
314
+
299
315
  async function testsCommand(chosen) {
300
316
  const root = rootOf(chosen);
301
317
  const what = chosen._[1];
@@ -343,6 +359,7 @@ export async function main(argv = process.argv.slice(2)) {
343
359
  case 'check': return checkCommand(chosen);
344
360
  case 'ste': return steCommand(chosen);
345
361
  case 'design': return designCommand(chosen);
362
+ case 'view': return viewCommand(chosen);
346
363
  case 'tests': return testsCommand(chosen);
347
364
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
348
365
  }
@@ -5,12 +5,22 @@
5
5
  //
6
6
  // The check flags what a script can see: a needed part that is missing or
7
7
  // empty, a state line (state lives in the tracker), a code with no row in
8
- // the Key, a part the registry does not know, and a figure whose tags do not
9
- // close. It proves the shape, not the quality. It never rewrites a word.
8
+ // the Key, a part the registry does not know, a figure whose tags do not
9
+ // close or that woodcut cannot draw, and a part with no picture where the
10
+ // registry asks for one. It proves the shape, not the quality. It never
11
+ // rewrites a word.
12
+ //
13
+ // Pictures are needed (owner, 21 October 2026): "any user is a visual
14
+ // person, they need to see things flowing to make sense of it." The `picture`
15
+ // key in parts.yaml says which part needs one figure, and that each story
16
+ // needs a walkthrough figure and a worked example.
10
17
  import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
11
18
  import { join, dirname, resolve, basename } from 'node:path';
12
19
  import { fileURLToPath } from 'node:url';
13
20
  import { parseYaml, insideRepo } from '../../shape/check.mjs';
21
+ import { WOODCUT_TYPES, fenceOpens, fenceCloses } from './markdown.mjs';
22
+
23
+ export { WOODCUT_TYPES };
14
24
 
15
25
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
16
26
  export const PARTS_FILE = join(PACKAGE_ROOT, 'skills', 'sdlc-task', 'design', 'parts.yaml');
@@ -20,6 +30,11 @@ export const NEEDS = Object.freeze(['needed', 'optional', 'none', 'shared']);
20
30
  export const DESIGN_KEYS = Object.freeze(['title', 'item', 'product', 'kinds', 'shared', 'look']);
21
31
  export const STATE_KEYS = Object.freeze(['status', 'state', 'stage', 'approved', 'locked', 'lock', 'shipped', 'delivered']);
22
32
  export const PART_KEYS = Object.freeze(['part', 'title']);
33
+ // What a part's `picture` key may say: `one` means the part holds at least one
34
+ // figure; `each-story` means each story holds a walkthrough figure and a
35
+ // worked example.
36
+ export const PICTURES = Object.freeze(['one', 'each-story']);
37
+
23
38
 
24
39
  // A code: capitals then a number, such as M1, A1.7, US-3 or D-6. Common
25
40
  // technical names that look like codes are not codes.
@@ -48,6 +63,7 @@ export function loadRegistry(path = PARTS_FILE) {
48
63
  const data = parseYaml(readFileSync(path, 'utf8'));
49
64
  const kinds = data.kinds.map((one) => one.id);
50
65
  for (const part of data.parts) {
66
+ if (part.picture !== undefined && !PICTURES.includes(part.picture)) throw new Error(`${basename(path)}: part "${part.id}" has picture "${part.picture}"; use ${PICTURES.join(' or ')}`);
51
67
  for (const kind of kinds) {
52
68
  if (!NEEDS.includes(part[kind])) throw new Error(`${basename(path)}: part "${part.id}" has no valid "${kind}" value; use ${NEEDS.join(', ')}`);
53
69
  }
@@ -103,9 +119,9 @@ function scanLines(body, firstLine) {
103
119
  let fence = null;
104
120
  body.split('\n').forEach((text, index) => {
105
121
  const line = firstLine + index;
106
- const open = text.match(/^\s*```+\s*(\S*)(?:\s+(\S+))?/);
107
- if (open && !fence) { fence = { info: open[1], type: open[2] ?? null, line, lines: [] }; return; }
108
- if (fence && /^\s*```+\s*$/.test(text)) { if (fence.info === 'figure' || fence.info === 'woodcut') figures.push(fence); fence = null; return; }
122
+ if (fence && fenceCloses(text, fence.mark)) { if (fence.info === 'figure' || fence.info === 'woodcut') figures.push(fence); fence = null; return; }
123
+ const mark = !fence && fenceOpens(text);
124
+ if (mark) { const [info, type] = text.trim().slice(mark.length).trim().split(/\s+/); fence = { mark, info: info ?? '', type: type ?? null, line, lines: [] }; return; }
109
125
  if (fence) { fence.lines.push(text); return; }
110
126
  prose.push({ text, line });
111
127
  });
@@ -143,6 +159,175 @@ export function figureFault(html) {
143
159
  return stack.length ? `<${stack[stack.length - 1]}> does not close` : null;
144
160
  }
145
161
 
162
+ // Why woodcut cannot draw this figure, or null. It reads the shape the
163
+ // renderer reads: a viewBox, the lists the type draws, the fields each entry
164
+ // needs, and the ids each step names. Any other throw is a finding too: a
165
+ // figure the check cannot read is a figure the page cannot draw.
166
+ export function woodcutFault(type, data) {
167
+ try { return woodcutShapeFault(type, data); } catch (error) { return `woodcut cannot read the figure data: ${error.message}`; }
168
+ }
169
+
170
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
171
+ // A list the renderer walks: absent is an empty list; anything else must be
172
+ // an array of objects.
173
+ function listOf(v, key) {
174
+ if (v[key] === undefined) return { list: [] };
175
+ if (!Array.isArray(v[key])) return { fault: `"${key}" must be a list` };
176
+ if (!v[key].every(isObject)) return { fault: `each entry of "${key}" must be an object` };
177
+ return { list: v[key] };
178
+ }
179
+
180
+ function woodcutShapeFault(type, data) {
181
+ const shape = WOODCUT_TYPES[type];
182
+ if (!shape) return `woodcut has no figure "${type}", so the page draws nothing; use ${Object.keys(WOODCUT_TYPES).join(', ')}, or draw your own in a \`figure\` block`;
183
+ if (!isObject(data)) return 'the woodcut data is not one JSON object';
184
+ if (data.variants !== undefined && !Array.isArray(data.variants)) return 'the woodcut "variants" must be a list';
185
+ const variants = data.variants ?? [data];
186
+ if (!variants.length) return 'the woodcut "variants" list is empty';
187
+ for (const [n, v] of variants.entries()) {
188
+ const at = variants.length > 1 ? ` in variant ${n + 1}` : '';
189
+ if (!isObject(v)) return `variant ${n + 1} is not an object`;
190
+ if (!Array.isArray(v.viewBox) || v.viewBox.length !== 2 || !v.viewBox.every(Number.isFinite)) return `the woodcut data needs "viewBox": [width, height]${at}`;
191
+ const lists = {};
192
+ for (const key of [shape.needs, ...shape.ids, ...(shape.more ?? []), 'scenarios']) {
193
+ const { list, fault } = listOf(v, key);
194
+ if (fault) return fault + at;
195
+ lists[key] = list;
196
+ }
197
+ if (!lists[shape.needs].length) return `a ${type} needs a non-empty "${shape.needs}" list${at}`;
198
+ if (lists[shape.needs].some((one) => one.id === undefined)) return `each entry of "${shape.needs}" needs an "id"${at}`;
199
+ const fault = elementFault(type, v, lists);
200
+ if (fault) return fault + at;
201
+ const ids = new Set(shape.ids.flatMap((key) => lists[key].map((one) => one.id)).filter((id) => id !== undefined));
202
+ if (type === 'wc-state-machine' && v.start) ids.add('start');
203
+ for (const sc of lists.scenarios) {
204
+ if (!Array.isArray(sc.steps)) return `the scenario "${sc.name ?? sc.id ?? '?'}" needs a "steps" list${at}`;
205
+ for (const step of sc.steps) {
206
+ if (!isObject(step)) return `each step of the scenario "${sc.name ?? sc.id ?? '?'}" must be an object${at}`;
207
+ for (const key of ['active', 'denied']) if (step[key] !== undefined && !Array.isArray(step[key])) return `a step's "${key}" must be a list of ids${at}`;
208
+ for (const id of [...(step.active ?? []), ...(step.denied ?? [])]) {
209
+ if (!ids.has(id)) return `a step names "${id}", which is not an element of the figure${at}`;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // The fields each element needs, or the renderer throws and the figure stays
218
+ // blank: a place for each box, the points of each line, and a place for each
219
+ // label on a line.
220
+ const num = (value) => Number.isFinite(value);
221
+ const pair = (value) => Array.isArray(value) && value.length === 2 && value.every(num);
222
+ function elementFault(type, v, lists) {
223
+ const name = (one) => `"${one.id ?? one.label ?? '?'}"`;
224
+ if (type === 'wc-sequence') {
225
+ const actors = new Set(lists.actors.map((one) => one.id));
226
+ for (const a of lists.actors) if (!num(a.x)) return `the actor ${name(a)} needs a number "x"`;
227
+ for (const m of lists.messages) {
228
+ if (!num(m.y)) return `the message ${name(m)} needs a number "y"`;
229
+ if (!actors.has(m.from) || !actors.has(m.to)) return `the message ${name(m)} names an actor that is not in "actors"`;
230
+ }
231
+ for (const act of lists.activations) if (!actors.has(act.actor) || !num(act.y1) || !num(act.y2)) return `each activation needs an "actor" from "actors" and numbers "y1" and "y2"`;
232
+ return null;
233
+ }
234
+ if (type === 'wc-state-machine') {
235
+ for (const st of lists.states) if (![st.x, st.y, st.w, st.h].every(num)) return `the state ${name(st)} needs numbers "x", "y", "w" and "h"`;
236
+ for (const t of lists.transitions) {
237
+ if (typeof t.path !== 'string' || !pair(t.tip) || !pair(t.tipDir)) return `the transition ${name(t)} needs "path", "tip": [x, y] and "tipDir": [dx, dy]`;
238
+ if (t.label && !pair(t.labelAt)) return `the transition ${name(t)} has a label, so it needs "labelAt": [x, y]`;
239
+ }
240
+ if (v.start !== undefined && !(isObject(v.start) && pair(v.start.at))) return 'the start needs "at": [x, y]';
241
+ return null;
242
+ }
243
+ for (const n of lists.nodes) {
244
+ if (n.shape === 'end' ? ![n.x, n.y].every(num) : ![n.x, n.y, n.w, n.h].every(num)) return `the node ${name(n)} needs numbers "x", "y", "w" and "h"`;
245
+ if (n.shape === 'end' && n.label && !pair(n.labelAt)) return `the end node ${name(n)} has a label, so it needs "labelAt": [x, y]`;
246
+ }
247
+ for (const e of lists.edges) {
248
+ const fewest = e.noTip ? 1 : 2;
249
+ if (!Array.isArray(e.points) || e.points.length < fewest || !e.points.every(pair)) return `the edge ${name(e)} needs "points": ${fewest === 1 ? 'one or more' : 'two or more'} [x, y] pairs`;
250
+ if (e.label && !pair(e.labelAt)) return `the edge ${name(e)} has a label, so it needs "labelAt": [x, y]`;
251
+ }
252
+ for (const lane of lists.lanes) if (![lane.y1, lane.y2].every(num)) return `the lane "${lane.label ?? '?'}" needs numbers "y1" and "y2"`;
253
+ for (const g of lists.groups) if (![g.x, g.y, g.w, g.h].every(num)) return `the group "${g.label ?? '?'}" needs numbers "x", "y", "w" and "h"`;
254
+ return null;
255
+ }
256
+
257
+ // A woodcut figure walks when one of its scenarios has two or more steps and
258
+ // each step has a title: the reader steps through it, and the panel tells
259
+ // what happens at each step. It runs only on data that woodcutFault passed.
260
+ export function woodcutWalks(data) {
261
+ const variants = Array.isArray(data?.variants) ? data.variants : [data];
262
+ return variants.some((v) => (v?.scenarios ?? []).some((sc) => Array.isArray(sc.steps) && sc.steps.length >= 2 && sc.steps.every((step) => step && step.title)));
263
+ }
264
+
265
+ // A custom figure counts as a picture when it draws: an SVG shape, or HTML
266
+ // with at least three elements. An empty <svg> or a line of text is not one.
267
+ const SHAPE = /<(?:rect|circle|ellipse|path|line|polyline|polygon|text|image)\b/i;
268
+ export function figureDraws(html) {
269
+ const text = html.replace(/<!--[\s\S]*?-->/g, '');
270
+ return SHAPE.test(text) || (text.match(/<[a-zA-Z][\w-]*\b/g)?.length ?? 0) >= 3;
271
+ }
272
+
273
+ // A story heading carries a code, such as "### US-1: a test goes red". The
274
+ // stories are the coded headings at the level of the first one; a deeper
275
+ // heading, or an uncoded heading such as "## Overview", stays inside the story
276
+ // before it. HTML comments and fenced blocks hold no stories.
277
+ const STORY_CODE = /(?<![\w./-])[A-Z]{1,4}-?\d+(?:\.\d+)*(?![\w/-])/;
278
+ function storiesOf(body, firstLine) {
279
+ const rows = [];
280
+ let fence = null;
281
+ let comment = false;
282
+ body.split('\n').forEach((text, index) => {
283
+ const line = firstLine + index;
284
+ if (fence) { if (fenceCloses(text, fence)) fence = null; rows.push({ text, line }); return; }
285
+ const open = fenceOpens(text);
286
+ if (open) { fence = open; rows.push({ text, line }); return; }
287
+ if (comment) { if (text.includes('-->')) comment = false; rows.push({ text, line }); return; }
288
+ if (/^\s*<!--/.test(text) && !text.includes('-->')) { comment = true; rows.push({ text, line }); return; }
289
+ const heading = text.match(/^(#{2,6})\s+(.*)$/);
290
+ rows.push({ text, line, heading: heading && STORY_CODE.test(heading[2].replace(/`[^`]*`/g, '')) ? { level: heading[1].length, name: heading[2].trim() } : null });
291
+ });
292
+ const level = rows.find((row) => row.heading)?.heading.level;
293
+ const stories = [];
294
+ for (const row of rows) {
295
+ if (row.heading && row.heading.level === level) stories.push({ name: row.heading.name, line: row.line, lines: [] });
296
+ else if (stories.length) stories[stories.length - 1].lines.push(row.text);
297
+ }
298
+ return stories;
299
+ }
300
+
301
+ // A worked example: a label that names the input and one that names the
302
+ // output, such as "**Input:** ..." and "**Output:** ...". "Ask" and "Answer",
303
+ // "Given" and "Result" count too, and so does a table with Input and Output
304
+ // columns.
305
+ const LABEL = (words) => new RegExp(`(?:^|[^\\w])(?:\\*\\*|__)?(?:${words})(?:\\*\\*|__)?\\s*(?::|—|–)|(?:\\*\\*|__)(?:${words})[:.](?:\\*\\*|__)|^\\s*\\|.*\\b(?:${words})\\b.*\\|`, 'i');
306
+ const EXAMPLE_INPUT = LABEL('inputs?|ask|given|example input');
307
+ const EXAMPLE_OUTPUT = LABEL('outputs?|answer|result|example output');
308
+ // A part that says "No change" and why needs no picture, as the empty rule allows.
309
+ const NO_CHANGE = /^\s*(?:#{1,6}\s.*\n\s*)*(?:\*\*|__)?no change\b/i;
310
+
311
+ function pictureFindings(one, rule, figures, finding) {
312
+ if (NO_CHANGE.test(one.body)) return;
313
+ const drawn = figures.filter((figure) => !figure.open && !figure.fault && (figure.info !== 'figure' || figureDraws(figure.lines.join('\n'))));
314
+ if (rule === 'one') {
315
+ if (!drawn.length) finding(one.file, one.bodyLine, 'picture', `part "${one.part}" needs a figure; a reader must see it, not only read it. Add a \`woodcut\` or a \`figure\` block`);
316
+ return;
317
+ }
318
+ const stories = storiesOf(one.body, one.bodyLine);
319
+ if (!stories.length) { finding(one.file, one.bodyLine, 'picture', 'write each story under a heading with its code, such as "### US-1: a test goes red", with a walkthrough figure and a worked example'); return; }
320
+ for (const story of stories) {
321
+ const inside = drawn.filter((figure) => figure.line > story.line && figure.line <= story.line + story.lines.length);
322
+ const walks = inside.some((figure) => figure.info === 'figure' || figure.walks);
323
+ if (!walks) finding(one.file, story.line, 'picture', `the story "${story.name}" needs a walkthrough figure: a woodcut figure with a scenario whose steps each have a title, or a figure of your own`);
324
+ const words = story.lines;
325
+ if (!words.some((line) => EXAMPLE_INPUT.test(line)) || !words.some((line) => EXAMPLE_OUTPUT.test(line))) {
326
+ finding(one.file, story.line, 'picture', `the story "${story.name}" needs a worked example: an "Input:" line and an "Output:" line with real values`);
327
+ }
328
+ }
329
+ }
330
+
146
331
  export function checkDesign(design, registry = loadRegistry()) {
147
332
  const findings = [];
148
333
  const finding = (file, line, rule, why) => findings.push({ file, line, rule, why });
@@ -196,10 +381,17 @@ export function checkDesign(design, registry = loadRegistry()) {
196
381
  let fault = figure.open ? 'the figure block does not close' : null;
197
382
  if (!fault && figure.info === 'woodcut') {
198
383
  if (!/^wc-[a-z-]+$/.test(figure.type ?? '')) fault = 'a woodcut block names its figure type, such as "woodcut wc-flowchart"';
199
- else { try { JSON.parse(figure.lines.join('\n')); } catch (error) { fault = `the woodcut data is not JSON: ${error.message}`; } }
384
+ else {
385
+ let data;
386
+ try { data = JSON.parse(figure.lines.join('\n')); } catch (error) { fault = `the woodcut data is not JSON: ${error.message}`; }
387
+ if (!fault) fault = woodcutFault(figure.type, data);
388
+ if (!fault) figure.walks = woodcutWalks(data);
389
+ }
200
390
  } else if (!fault) fault = figureFault(figure.lines.join('\n'));
201
- if (fault) finding(one.file, figure.line, 'figure', fault);
391
+ if (fault) { figure.fault = fault; finding(one.file, figure.line, 'figure', fault); }
202
392
  }
393
+ const picture = registry.byId.get(one.part).picture;
394
+ if (picture && !isEmpty(one.body)) pictureFindings(one, picture, figures, finding);
203
395
  }
204
396
  for (const [id, { need, by }] of needs) {
205
397
  if (need === 'needed' && !seen.has(id)) {
@@ -6,6 +6,31 @@
6
6
  //
7
7
  // No dependencies. The text is the repo's own; a custom figure is trusted
8
8
  // the same way a page in the repo is.
9
+ // The figure types that woodcut 0.4.0 defines (WOODCUT in design-build.mjs), and the list each one cannot
10
+ // draw without. A name outside this map is an element the browser does not
11
+ // know, so it draws nothing. The ids lists name the arrays whose ids a
12
+ // walkthrough step may name.
13
+ export const WOODCUT_TYPES = Object.freeze({
14
+ 'wc-block-diagram': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
15
+ 'wc-flowchart': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
16
+ 'wc-swimlane': { needs: 'nodes', ids: ['nodes', 'edges', 'groups'], more: ['lanes'] },
17
+ 'wc-sequence': { needs: 'actors', ids: ['actors', 'messages'], more: ['activations'] },
18
+ 'wc-state-machine': { needs: 'states', ids: ['states', 'transitions'] },
19
+ });
20
+
21
+ // One fence rule for every reader of a page (the check, this renderer and
22
+ // atlas ste), as CommonMark has it: a fence opens with three or more of one
23
+ // mark, and closes only on a run of the same mark at least as long, with
24
+ // nothing after it. So a ```` block may hold a ``` block.
25
+ export function fenceOpens(line) {
26
+ const match = /^\s*(`{3,}|~{3,})/.exec(line);
27
+ return match ? match[1] : null;
28
+ }
29
+ export function fenceCloses(line, mark) {
30
+ const match = /^\s*(`{3,}|~{3,})\s*$/.exec(line);
31
+ return Boolean(match) && match[1][0] === mark[0] && match[1].length >= mark.length;
32
+ }
33
+
9
34
  export const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10
35
  export const slug = (text) => String(text).toLowerCase().replace(/<[^>]+>/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
11
36
 
@@ -60,16 +85,19 @@ export function render(body, ctx) {
60
85
  };
61
86
  while (i < lines.length) {
62
87
  const line = lines[i];
63
- const fence = line.match(/^\s*```+\s*(.*)$/);
64
- if (fence) {
88
+ const mark = fenceOpens(line);
89
+ if (mark) {
65
90
  flush();
66
- const info = fence[1].trim();
91
+ const info = line.trim().slice(mark.length).trim();
67
92
  const buf = [];
68
93
  i += 1;
69
- while (i < lines.length && !/^\s*```+\s*$/.test(lines[i])) buf.push(lines[i++]);
94
+ while (i < lines.length && !fenceCloses(lines[i], mark)) buf.push(lines[i++]);
70
95
  i += 1;
71
96
  const woodcut = info.match(/^woodcut\s+(wc-[a-z-]+)/);
72
- if (woodcut) {
97
+ if (woodcut && !WOODCUT_TYPES[woodcut[1]]) {
98
+ // A draft page shows the fault where the figure should be, never a blank.
99
+ out.push(`<p class="draft">This figure does not draw: woodcut has no figure "${esc(woodcut[1])}". Run atlas design check.</p>`);
100
+ } else if (woodcut) {
73
101
  let data;
74
102
  try { data = JSON.parse(buf.join('\n')); } catch (error) { throw new Error(`${ctx.file}: bad woodcut JSON in ${woodcut[1]}: ${error.message}`); }
75
103
  ctx.figures.push(woodcut[1]);
package/door/lib/ste.mjs CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
13
  import { dirname, extname, join, relative } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
16
+ import { fenceOpens, fenceCloses } from './markdown.mjs';
16
17
 
17
18
  export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
18
19
 
@@ -64,9 +65,10 @@ export function markdownUnits(text) {
64
65
  for (; index < lines.length; index += 1) {
65
66
  const line = lines[index];
66
67
  const number = index + 1;
67
- const fenceMark = /^\s*(```|~~~)/.exec(line);
68
- if (fence) { if (fenceMark && fenceMark[1] === fence) fence = null; continue; }
69
- if (fenceMark) { close(); fence = fenceMark[1]; continue; }
68
+ // The fence rule of the design renderer: a ```` block may hold a ``` block.
69
+ if (fence) { if (fenceCloses(line, fence)) fence = null; continue; }
70
+ const mark = fenceOpens(line);
71
+ if (mark) { close(); fence = mark; continue; }
70
72
  if (!line.trim()) { close(); continue; }
71
73
  if (/^\s*>/.test(line)) { close(); continue; }
72
74
  if (comment) { if (line.includes('-->')) comment = false; continue; }
@@ -0,0 +1,46 @@
1
+ // The project view page builder: `atlas view build`. It puts the view rules
2
+ // (work/lib/view-rules.mjs) and one product id into the page template
3
+ // (view/page.html), and writes one page. The page holds no record data: it
4
+ // reads the tracker at open, through the viewer's Engram connector.
5
+ // Design: docs/design-docs/project-view (tracker item 6dd1761a).
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ // The one connector grant the page needs, for the Artifact publish. Both
10
+ // tools only read.
11
+ export const VIEW_CONNECTOR = Object.freeze({ server: 'Engram', tools: Object.freeze(['entities_list', 'get']) });
12
+ export const viewCapabilities = () => ({ mcp: { servers: [{ server: VIEW_CONNECTOR.server, tools: [...VIEW_CONNECTOR.tools] }] } });
13
+
14
+ const PRODUCT_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
15
+ const quoted = (value) => JSON.stringify(String(value)).slice(1, -1).replace(/</g, '\\u003c');
16
+ const plain = (value) => String(value).replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]);
17
+
18
+ export function buildViewPage({ packageRoot, product, name, version }) {
19
+ if (!PRODUCT_ID.test(String(product ?? ''))) throw new Error('give a product id: atlas view build --product <id>, such as atlas');
20
+ const label = String(name ?? '').trim() || product.charAt(0).toUpperCase() + product.slice(1);
21
+ if (label.length > 80) throw new Error('--name is at most 80 characters');
22
+ const template = readFileSync(join(packageRoot, 'view', 'page.html'), 'utf8');
23
+ const rules = readFileSync(join(packageRoot, 'work', 'lib', 'view-rules.mjs'), 'utf8');
24
+ if (/<\/script/i.test(rules)) throw new Error('view-rules.mjs holds "</script", so it cannot go into a page');
25
+ const swaps = {
26
+ '/*@@VIEW_RULES@@*/': rules,
27
+ '@@PRODUCT@@': quoted(product),
28
+ '@@PRODUCT_NAME@@': quoted(label),
29
+ '@@ATLAS_VERSION@@': quoted(version ?? ''),
30
+ '@@TITLE@@': plain(`${label} project view`),
31
+ };
32
+ let html = template;
33
+ for (const [mark, value] of Object.entries(swaps)) {
34
+ if (!html.includes(mark)) throw new Error(`the page template has no ${mark}`);
35
+ html = html.split(mark).join(value);
36
+ }
37
+ if (/@@[A-Z_]+@@/.test(html)) throw new Error('the page template has a mark the builder does not fill');
38
+ return { html, product, name: label };
39
+ }
40
+
41
+ export function writeViewPage(out, options) {
42
+ const page = buildViewPage(options);
43
+ mkdirSync(dirname(out), { recursive: true });
44
+ writeFileSync(out, page.html);
45
+ return page;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.22",
3
+ "version": "0.3.25",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -21,6 +21,7 @@
21
21
  "work/",
22
22
  "shape/",
23
23
  "tests/",
24
+ "view/",
24
25
  "skills/",
25
26
  "agents/",
26
27
  ".claude-plugin/",
@@ -102,6 +102,7 @@ set. A test keeps the two lists equal.
102
102
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
103
103
  | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
104
104
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
105
+ | `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 |
105
106
 
106
107
  A person merges every file that `atlas tooling` writes.
107
108
  A person merges every file that `atlas tests write` writes.
@@ -114,7 +114,8 @@ A lock is the owner's approval of a short design.
114
114
  shows in bold at the top.
115
115
  2. **Show the design's page before you ask.** Run
116
116
  `atlas design check <folder>` and `atlas ste` on the folder, and fix each
117
- finding by hand. A part that needs a custom figure goes to the
117
+ finding by hand. The check needs pictures, as `design/README.md`
118
+ "Figures" says. A part that needs a custom figure goes to the
118
119
  `atlas:artifact-renderer` crew first. Run
119
120
  `atlas design build <folder> --tracker <item.json>`, with the tracker
120
121
  data that `design/README.md` names. Run `atlas ste` on the page and
@@ -90,12 +90,10 @@ gets. The kind sets which parts the design has. The two are separate: an
90
90
  initiative-tier piece of work can be a Decide design, and an Initiative
91
91
  design holds a family of designs.
92
92
 
93
- At initiative tier, a Decide design goes deep:
94
-
95
- 1. The Problem today part has a figure of today. The Proposal part has a
96
- figure of the target.
97
- 2. At least two options get full depth in Alternatives: their costs, their
98
- risks and why each one lost.
93
+ At every tier, the Problem today part has a figure of today and the
94
+ Proposal part has a figure of the target. At initiative tier, a Decide
95
+ design also goes deep. At least two options get full depth in
96
+ Alternatives: their costs, their risks and why each one lost.
99
97
 
100
98
  At standard tier, one chosen approach and one rejected option are enough.
101
99
 
@@ -167,18 +165,48 @@ Without the key, the design folder is `docs/design-docs`.
167
165
  An empty part fails the check.
168
166
  4. Put every code you use, such as `US-3` or `M1`, in the Key.
169
167
  5. Never write a state line, such as "Status: approved". The check flags it.
168
+ 6. Draw the parts that the Figures section names. The check fails a part
169
+ with no figure where one is needed.
170
170
 
171
171
  ## Figures
172
172
 
173
- 1. Use a woodcut figure when a stock figure fits: a flow chart, a sequence,
174
- a state machine or swim lanes. Use a fenced block with the info string
175
- `woodcut wc-<type>`. It holds the figure data as JSON, in the woodcut
176
- shape.
177
- 2. Draw your own figure when the subject has its own shape. Git lanes, a
173
+ A reader must see the design move, not only read it (owner, 21 October
174
+ 2026: "any user is a visual person, they need to see things flowing to make
175
+ sense of it"). The check enforces it. The `picture` key in
176
+ [parts.yaml](parts.yaml) says where:
177
+
178
+ | Part | The check needs |
179
+ |---|---|
180
+ | Summary | One figure. |
181
+ | Problem today | One figure of today. |
182
+ | Proposal | One figure of the target. |
183
+ | States | One state figure. |
184
+ | Stories | For each story: a walkthrough figure, and a worked example with real values. The example has an `Input:` label and an `Output:` label. `Ask` and `Answer`, or a table with Input and Output columns, also count. |
185
+
186
+ A story starts at a heading that holds its code, such as
187
+ `### US-1: a test goes red`. A deeper heading stays inside the story. A part
188
+ that says "No change" and why needs no figure.
189
+
190
+ A walkthrough figure is a woodcut figure with a scenario of two or more
191
+ steps, each with a title and one or two sentences. The reader clicks or
192
+ presses the arrow keys, and each step lights up the part that moves. A
193
+ figure of your own also counts.
194
+
195
+ 1. Use a woodcut figure when a stock figure fits. Use a fenced block with
196
+ the info string `woodcut <type>`. It holds the figure data as JSON.
197
+ The template for Stories has a full example.
198
+ 2. Pick the type by its question. `wc-sequence`: in what order?
199
+ `wc-swimlane`: who does it? `wc-flowchart`: which way?
200
+ `wc-state-machine`: what state is it in? `wc-block-diagram`: what are
201
+ the parts?
202
+ 3. Any other type name draws nothing, so the check refuses it. The check
203
+ also refuses data that the figure cannot draw. Examples are a missing
204
+ `viewBox`, a box with no place and a line with no points.
205
+ 4. Draw your own figure when the subject has its own shape. Git lanes, a
178
206
  time line and a data diff are examples. Use a fenced block with the info
179
207
  string `figure`. It holds the HTML. Use the page tokens, such as
180
208
  `var(--accent)` and `var(--line)`, so the figure works in both themes.
181
- 3. Both kinds follow the same rules. A step has a title and one or two
209
+ 5. Both kinds follow the same rules. A step has a title and one or two
182
210
  sentences. The reader sets the pace. The figure prints as stills.
183
211
  Meaning is never by colour alone.
184
212
 
@@ -8,7 +8,8 @@ Rules:
8
8
 
9
9
  1. Say what happens today, with one real example.
10
10
  2. Cite the source: a pull request, an issue or a log line.
11
- 3. Do not propose a fix here.
11
+ 3. Draw today with a figure. The check fails the part with no figure.
12
+ 4. Do not propose a fix here.
12
13
 
13
14
  Example, in a file of its own:
14
15
 
@@ -19,4 +20,20 @@ part: problem
19
20
  # Problem today
20
21
 
21
22
  Today the tests run after the merge. On 2 October pull request 88 merged, and its tests went red one hour later.
23
+
24
+ ```woodcut wc-flowchart
25
+ {
26
+ "label": "Today: the tests run after the merge",
27
+ "viewBox": [460, 70],
28
+ "nodes": [
29
+ { "id": "pr", "x": 10, "y": 18, "w": 110, "h": 34, "label": "Pull request", "shape": "pill" },
30
+ { "id": "merge", "x": 175, "y": 18, "w": 110, "h": 34, "label": "Merge" },
31
+ { "id": "tests", "x": 340, "y": 18, "w": 110, "h": 34, "label": "Tests: red", "kind": "accent" }
32
+ ],
33
+ "edges": [
34
+ { "id": "e1", "points": [[120, 35], [173, 35]] },
35
+ { "id": "e2", "points": [[285, 35], [338, 35]] }
36
+ ]
37
+ }
38
+ ```
22
39
  ````
@@ -8,7 +8,7 @@ Rules:
8
8
 
9
9
  1. Name the option we pick, and say how it works in steps.
10
10
  2. Give a worked example: the input, the process and the output.
11
- 3. Link the figure that shows it.
11
+ 3. Draw the target with a figure. The check fails the part with no figure.
12
12
 
13
13
  Example, in a file of its own:
14
14
 
@@ -18,7 +18,35 @@ part: proposal
18
18
  ---
19
19
  # Proposal
20
20
 
21
+ The tests run before the merge.
22
+
23
+ ```woodcut wc-flowchart
24
+ {
25
+ "label": "Target: the tests run before the merge",
26
+ "viewBox": [460, 70],
27
+ "nodes": [
28
+ { "id": "pr", "x": 10, "y": 18, "w": 110, "h": 34, "label": "Pull request", "shape": "pill" },
29
+ { "id": "tests", "x": 175, "y": 18, "w": 110, "h": 34, "label": "Tests", "kind": "accent" },
30
+ { "id": "merge", "x": 340, "y": 18, "w": 110, "h": 34, "label": "Merge" }
31
+ ],
32
+ "edges": [
33
+ { "id": "e1", "points": [[120, 35], [173, 35]] },
34
+ { "id": "e2", "points": [[285, 35], [338, 35]], "label": "green only", "labelAt": [311, 12] }
35
+ ],
36
+ "scenarios": [{
37
+ "name": "Pull request 88",
38
+ "steps": [
39
+ { "title": "A pull request opens", "body": "Pull request 88 changes src/pay.mjs.", "active": ["pr"] },
40
+ { "title": "The gate runs the tests", "body": "The test pay rounds down goes red.", "active": ["e1", "tests"] },
41
+ { "title": "The merge waits", "body": "Only a green run opens the merge.", "active": ["e2", "merge"] }
42
+ ]
43
+ }]
44
+ }
45
+ ```
46
+
21
47
  1. A pull request opens.
22
48
  2. The gate runs the tests on its head.
23
49
  3. A red test blocks the merge button.
50
+
51
+ Input: pull request 88 with one failing test. Output: the merge button stays grey.
24
52
  ````