@golden-frijoles/kit 0.15.0 → 0.17.0

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.
@@ -110,8 +110,57 @@ export function siblingDocs(readmeRelPath) {
110
110
 
111
111
  // ── Individual checkers — each returns a list of { rule, detail } offenses for one file's content ──
112
112
 
113
+ // An HTML comment that never closes hides EVERYTHING after it when the doc renders — the whole epic README, its
114
+ // architecture lock included (think-skills and sketch-specs both shipped one, 2026-09-30, from an edit anchored on a
115
+ // heading the old template's comment named). Code is skipped first: a `<!-- jev:` inside backticks or a fence is an
116
+ // example, not a comment, and several sprint docs quote one.
117
+ // A code span opens with a run of backticks and closes with the same run (``<!-- x`` included).
118
+ const CODE_SPAN = /(`+)[\s\S]*?\1/g;
119
+
120
+ // A later `-->` closes an open comment wherever it appears (a mermaid `A --> B` included) — that is what CommonMark
121
+ // renders, so the rule agrees with the page; the text in between is still hidden, and only reading it shows that.
122
+ export function unclosedComments(content) {
123
+ const lines = content.split('\n');
124
+ let fence = null;
125
+ let open = null; // the line an unclosed <!-- started on, while we are inside one
126
+ for (let i = 0; i < lines.length; i++) {
127
+ // The whole run of marks: a ```` fence is closed only by ```` or longer, never by the ``` it quotes.
128
+ const fenceMark = lines[i].match(/^ {0,3}(`{3,}|~{3,})/);
129
+ if (open === null && fenceMark) {
130
+ const mark = fenceMark[1];
131
+ if (fence === null) fence = mark;
132
+ else if (mark[0] === fence[0] && mark.length >= fence.length) fence = null;
133
+ continue;
134
+ }
135
+ if (fence !== null) continue;
136
+ let rest = open === null ? lines[i].replace(CODE_SPAN, '') : lines[i];
137
+ for (;;) {
138
+ if (open === null) {
139
+ const at = rest.indexOf('<!--');
140
+ if (at === -1) break;
141
+ open = i;
142
+ rest = rest.slice(at + 4);
143
+ } else {
144
+ const at = rest.indexOf('-->');
145
+ if (at === -1) break;
146
+ open = null;
147
+ rest = rest.slice(at + 3).replace(CODE_SPAN, '');
148
+ }
149
+ }
150
+ }
151
+ return open === null
152
+ ? []
153
+ : [
154
+ {
155
+ rule: 'unclosed-html-comment',
156
+ detail: `line ${open + 1} opens <!-- and nothing closes it — everything after it is hidden when the doc renders`,
157
+ },
158
+ ];
159
+ }
160
+
113
161
  export function checkEpicReadme(content, { slug, exists = existsRelative } = {}) {
114
162
  const offenses = [];
163
+ offenses.push(...unclosedComments(content));
115
164
  // Many epics predate the seeds/ convention and genuinely have no seed file to link — that's an
116
165
  // accepted state (Sprint 2 sweep decision), not drift. Only require a **Scope seed:** field when a
117
166
  // real seed file exists for this epic's slug; a seed that exists but isn't linked IS still flagged.
@@ -230,6 +279,7 @@ export function bodyStartIndex(lines) {
230
279
 
231
280
  export function checkSprintDoc(content) {
232
281
  const offenses = [];
282
+ offenses.push(...unclosedComments(content));
233
283
  const all = content.split('\n');
234
284
  const statusLine = all
235
285
  .slice(bodyStartIndex(all))
@@ -331,6 +381,7 @@ export const RETRO_SECTION_STEMS = [
331
381
  // same date — that is fixing drift, not relaxing the check.
332
382
  export function checkRetrospective(content) {
333
383
  const offenses = [];
384
+ offenses.push(...unclosedComments(content));
334
385
  const closedLine = content.split('\n').find((l) => /closed/i.test(l) && l.trim() !== '');
335
386
  if (!closedLine) {
336
387
  offenses.push({ rule: 'retro-closed-missing', detail: 'no "Closed" date line found near the top' });
@@ -0,0 +1,303 @@
1
+ // surface.mjs — the `surface` block: one screen state written as an ordered list of blocks (sketch-specs D8–D10).
2
+ //
3
+ // ```surface
4
+ // state: ship-features
5
+ // route: /app/flags/[projectSlug]
6
+ // - head "Features" action "+ New feature"
7
+ // - summary count 4
8
+ // - list columns "feature | state in production | type & risk | on / off"
9
+ // ```
10
+ //
11
+ // One description serves three readers: the product owner (sketch-render.mjs draws it grey), the review (it is text
12
+ // in a seed) and a project's state contract (a project maps the generic kinds onto its own and compares the built
13
+ // page). So the grammar records only what survives a change of data: the kind of each block in order, the words
14
+ // that matter, and three facts — a primary action's words, a count, a list's column words. Never a value, a row
15
+ // count or a pixel.
16
+ //
17
+ // A line grammar rather than YAML because the kit has zero dependencies and no YAML parser; a hand-rolled YAML
18
+ // subset would accept things YAML readers reject and the other way round. Zero deps; no side effects at import.
19
+
20
+ /**
21
+ * The generic vocabulary: twelve kinds, each with the facts it may carry (D10).
22
+ *
23
+ * A fact on a kind that cannot carry it is an error, not ignored: a `count` on an `answer` means the author
24
+ * believes something is counted, and a sketch that silently drops it approves a picture of a different screen.
25
+ */
26
+ export const SURFACE_KINDS = Object.freeze({
27
+ head: ['action'],
28
+ answer: [],
29
+ summary: ['count'],
30
+ tiles: ['count'],
31
+ toolbar: [],
32
+ list: ['columns'],
33
+ empty: [],
34
+ card: [],
35
+ steps: ['count'],
36
+ field: [],
37
+ tabs: [],
38
+ note: [],
39
+ });
40
+
41
+ export const SURFACE_FACTS = Object.freeze(['action', 'count', 'columns']);
42
+
43
+ const KIND_NAMES = Object.keys(SURFACE_KINDS);
44
+
45
+ // OWN properties only. `kind in SURFACE_KINDS` also answers true for `toString`, `constructor` and `__proto__`, which
46
+ // then parsed as kinds and crashed the renderer with a raw TypeError instead of a line-numbered error (#210 review).
47
+ const isKind = (name) => Object.hasOwn(SURFACE_KINDS, name);
48
+ const STATE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
49
+ const HEADER = /^([A-Za-z][\w-]*):\s*(.*)$/;
50
+
51
+ /** A parse error that knows where it happened. `message` is `<file>:<line>: <reason>`, ready to print. */
52
+ export class SurfaceError extends Error {
53
+ constructor(file, line, reason) {
54
+ super(`${file}:${line}: ${reason}`);
55
+ this.name = 'SurfaceError';
56
+ this.file = file;
57
+ this.line = line;
58
+ this.reason = reason;
59
+ }
60
+ }
61
+
62
+ function distance(a, b) {
63
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
64
+ for (let i = 1; i <= a.length; i += 1) {
65
+ let diagonal = row[0];
66
+ row[0] = i;
67
+ for (let j = 1; j <= b.length; j += 1) {
68
+ const above = row[j];
69
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
70
+ diagonal = above;
71
+ }
72
+ }
73
+ return row[b.length];
74
+ }
75
+
76
+ function unknownKind(kind) {
77
+ const [nearest] = KIND_NAMES.map((name) => [name, distance(kind, name)]).sort((x, y) => x[1] - y[1]);
78
+ const hint = nearest[1] <= 2 ? ` — did you mean \`${nearest[0]}\`?` : '';
79
+ return `unknown kind \`${kind}\`${hint} Known kinds: ${KIND_NAMES.join(', ')}.`;
80
+ }
81
+
82
+ /**
83
+ * Split the rest of a block line into quoted strings and bare words.
84
+ * Throws a plain string (the reason) so the caller attaches the line number once.
85
+ */
86
+ function tokenize(text) {
87
+ const tokens = [];
88
+ let i = 0;
89
+ while (i < text.length) {
90
+ if (/\s/.test(text[i])) {
91
+ i += 1;
92
+ continue;
93
+ }
94
+ if (text[i] === '"') {
95
+ let value = '';
96
+ i += 1;
97
+ for (;;) {
98
+ if (i >= text.length) throw 'unterminated string — a quoted string closes on the same line';
99
+ const char = text[i];
100
+ if (char === '\\') {
101
+ const next = text[i + 1];
102
+ if (next !== '"' && next !== '\\')
103
+ throw `unknown escape \`\\${next ?? ''}\` — only \\" and \\\\ are escapes`;
104
+ value += next;
105
+ i += 2;
106
+ } else if (char === '"') {
107
+ i += 1;
108
+ break;
109
+ } else {
110
+ value += char;
111
+ i += 1;
112
+ }
113
+ }
114
+ tokens.push({ quoted: true, value });
115
+ continue;
116
+ }
117
+ let word = '';
118
+ while (i < text.length && !/\s/.test(text[i])) {
119
+ if (text[i] === '"') throw `a quote inside \`${word}"\` — put a space before a quoted string`;
120
+ word += text[i];
121
+ i += 1;
122
+ }
123
+ tokens.push({ quoted: false, value: word });
124
+ }
125
+ return tokens;
126
+ }
127
+
128
+ /** Parse one block line (`<kind> ["words"] [fact value]…`, without its leading `- `). */
129
+ function parseBlockLine(rest) {
130
+ // The shape the seed template shipped before this grammar existed (`- heading: "Orders"`). Named, because the
131
+ // generic "unknown kind `heading:`" would send the author looking for a kind rather than at the format.
132
+ if (/^[a-z][\w-]*:(\s|$)/.test(rest)) {
133
+ throw (
134
+ '`- <kind>: "…"` is the old list shape — write `- <kind> "words"` (for example `- head "Orders"`); ' +
135
+ 'see groom/references/intent-and-visuals.md'
136
+ );
137
+ }
138
+ const tokens = tokenize(rest);
139
+ const first = tokens.shift();
140
+ if (first === undefined || first.quoted) throw 'a block line starts with its kind: `- <kind> …`';
141
+ const kind = first.value;
142
+ if (!isKind(kind)) throw unknownKind(kind);
143
+
144
+ const block = { kind, words: null };
145
+ if (tokens[0]?.quoted) block.words = tokens.shift().value;
146
+
147
+ while (tokens.length > 0) {
148
+ const name = tokens.shift();
149
+ if (name.quoted)
150
+ throw 'a second quoted string — a block has one words string; facts are `name value` pairs';
151
+ if (name.value === 'when:' || name.value === 'when') {
152
+ throw 'a surface is ONE state, so a block has no `when:` — write that state as its own surface block';
153
+ }
154
+ if (!SURFACE_FACTS.includes(name.value)) {
155
+ throw `unknown fact \`${name.value}\` — the facts are ${SURFACE_FACTS.join(', ')}`;
156
+ }
157
+ if (!SURFACE_KINDS[kind].includes(name.value)) {
158
+ const carries = SURFACE_KINDS[kind];
159
+ throw (
160
+ `\`${kind}\` carries no \`${name.value}\`` +
161
+ (carries.length > 0 ? ` (it carries: ${carries.join(', ')})` : ' (it carries no facts)')
162
+ );
163
+ }
164
+ if (Object.hasOwn(block, name.value)) throw `\`${name.value}\` given twice`;
165
+ const value = tokens.shift();
166
+ if (value === undefined) throw `\`${name.value}\` needs a value`;
167
+ if (name.value === 'count') {
168
+ // No leading zeros and no number past exact integers: `1e23` would be compared with a count read off a page.
169
+ if (value.quoted || !/^(0|[1-9]\d*)$/.test(value.value) || !Number.isSafeInteger(Number(value.value))) {
170
+ throw `\`count\` is a whole number, got \`${value.value}\``;
171
+ }
172
+ block.count = Number(value.value);
173
+ } else {
174
+ if (!value.quoted) throw `\`${name.value}\` takes a quoted string, got \`${value.value}\``;
175
+ if (name.value === 'action') block.action = value.value;
176
+ // A trailing bar is an unlabelled column — how a list's actions column reads today — so it is kept as "".
177
+ else block.columns = value.value.split('|').map((column) => column.trim());
178
+ }
179
+ }
180
+ return block;
181
+ }
182
+
183
+ /**
184
+ * Parse one bare surface block — the whole of a `.surface` file, or the body of a fence.
185
+ *
186
+ * @param {string} text the block's lines
187
+ * @param {string} file the name errors carry
188
+ * @param {number} firstLine the FILE line number of the text's first line, so an error inside a fenced block in a
189
+ * long seed points at the seed's line rather than the block's
190
+ * @returns {{ state: string, route: string, line: number, blocks: Array<{ kind: string, words: string|null,
191
+ * line: number, action?: string, count?: number, columns?: string[] }> }}
192
+ */
193
+ export function parseSurface(text, file = '<surface>', firstLine = 1) {
194
+ const surface = { state: null, route: null, line: firstLine, blocks: [] };
195
+ const lines = text.split(/\r?\n/);
196
+ lines.forEach((raw, index) => {
197
+ const line = firstLine + index;
198
+ const content = raw.trim();
199
+ if (content === '' || content.startsWith('#')) return;
200
+ try {
201
+ if (content.startsWith('- ') || content === '-') {
202
+ surface.blocks.push({ ...parseBlockLine(content.slice(1).trim()), line });
203
+ return;
204
+ }
205
+ const header = HEADER.exec(content);
206
+ if (header === null)
207
+ throw `expected \`state:\`, \`route:\` or a block line \`- <kind> …\`, got \`${content}\``;
208
+ const [, key, value = ''] = header;
209
+ if (key === 'blocks') {
210
+ throw 'no `blocks:` list — each block is its own line, `- <kind> "words"`, straight after `state:` and `route:`';
211
+ }
212
+ if (key === 'when')
213
+ throw 'a surface is ONE state, so it has no `when:` — write each state as its own block';
214
+ if (key !== 'state' && key !== 'route')
215
+ throw `unknown header \`${key}:\` — a surface has \`state:\` and \`route:\``;
216
+ if (surface.blocks.length > 0) throw `\`${key}:\` comes before the first block line`;
217
+ if (surface[key] !== null) throw `\`${key}:\` given twice`;
218
+ const trimmed = value.trim();
219
+ if (key === 'state' && !STATE_ID.test(trimmed)) {
220
+ throw `state id \`${trimmed}\` must be lower-case words joined by hyphens (e.g. \`ship-features-empty\`)`;
221
+ }
222
+ if (key === 'route' && !trimmed.startsWith('/')) throw `route \`${trimmed}\` must start with /`;
223
+ surface[key] = trimmed;
224
+ } catch (reason) {
225
+ if (typeof reason !== 'string') throw reason;
226
+ throw new SurfaceError(file, line, reason);
227
+ }
228
+ });
229
+ if (surface.state === null)
230
+ throw new SurfaceError(file, firstLine, 'no `state:` line — every surface names its state id');
231
+ if (surface.route === null)
232
+ throw new SurfaceError(file, firstLine, 'no `route:` line — every surface names its route');
233
+ if (surface.blocks.length === 0) {
234
+ throw new SurfaceError(file, firstLine, 'no blocks — a surface lists at least one `- <kind> …` line');
235
+ }
236
+ return surface;
237
+ }
238
+
239
+ // CommonMark fences: up to three spaces of indent (four is an indented code block), three or more backticks or
240
+ // tildes. EVERY fence is tracked, not only `surface` ones, because a seed shows examples inside a longer fence
241
+ // (```` … ````) and a `surface` fence inside one is text, not a block.
242
+ const FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
243
+
244
+ /** Every fenced `surface` block in a markdown file, in order. */
245
+ export function parseSurfaces(markdown, file = '<markdown>') {
246
+ const lines = markdown.split(/\r?\n/);
247
+ const surfaces = [];
248
+ let open = null;
249
+ lines.forEach((raw, index) => {
250
+ const fence = FENCE.exec(raw);
251
+ if (open === null) {
252
+ if (fence === null) return;
253
+ const [, , marks, info] = fence;
254
+ // A backtick fence's info string may not contain a backtick (CommonMark), so ```x``` is inline code.
255
+ if (marks[0] === '`' && info.includes('`')) return;
256
+ open = {
257
+ char: marks[0],
258
+ length: marks.length,
259
+ surface: info.trim() === 'surface',
260
+ line: index + 1,
261
+ body: [],
262
+ };
263
+ return;
264
+ }
265
+ const closes =
266
+ fence !== null && fence[2][0] === open.char && fence[2].length >= open.length && fence[3].trim() === '';
267
+ if (!closes) {
268
+ open.body.push(raw);
269
+ return;
270
+ }
271
+ if (open.surface) surfaces.push(parseSurface(open.body.join('\n'), file, open.line + 1));
272
+ open = null;
273
+ });
274
+ if (open?.surface) {
275
+ throw new SurfaceError(file, open.line, 'this `surface` fence is never closed');
276
+ }
277
+ return surfaces;
278
+ }
279
+
280
+ /**
281
+ * Check a project's `surface.map.json` (D11): `{ kinds: { <generic kind>: <project kind> } }`.
282
+ * Returns the problems, empty when the map is usable. Whether each VALUE is one of the project's own kinds only the
283
+ * project can say, so that half of the check lives with the project.
284
+ */
285
+ export function validateMap(map) {
286
+ if (
287
+ map === null ||
288
+ typeof map !== 'object' ||
289
+ map.kinds === null ||
290
+ typeof map.kinds !== 'object' ||
291
+ Array.isArray(map.kinds)
292
+ ) {
293
+ return ['the map has no `kinds` object'];
294
+ }
295
+ const problems = [];
296
+ for (const [generic, target] of Object.entries(map.kinds)) {
297
+ if (!isKind(generic)) problems.push(unknownKind(generic));
298
+ if (typeof target !== 'string' || target.trim() === '') {
299
+ problems.push(`\`${generic}\` maps to ${JSON.stringify(target)}, not a kind name`);
300
+ }
301
+ }
302
+ return problems;
303
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // sketch-render.mjs — draw every `surface` block in a file as a plain grey wireframe (sketch-specs D16).
3
+ //
4
+ // node scripts/sketch-render.mjs Roadmap/00-ideas/seeds/<seed>.md [--out <file.html>]
5
+ // node scripts/sketch-render.mjs apps/web/design-system/surfaces/<state>.surface
6
+ //
7
+ // The product owner approves a PICTURE, not a text list, so each state becomes one grey section: boxes in the
8
+ // order the blocks are written, with the words that matter and the facts drawn (a count as that many boxes, a
9
+ // list's column words over placeholder rows). Without --out the page goes to the OS temp folder, so a render never
10
+ // litters `Roadmap/`; the path is printed either way.
11
+ //
12
+ // ⚠️ **Deliberately grey, and deliberately blind to any design system (D4).** A wireframe in brand colours gets
13
+ // reviewed for its colours, and a renderer that imports the project's components becomes a second component
14
+ // library that drifts from the first. So this file imports only `node:*` and the parser, every colour is a grey,
15
+ // and a test fails on anything else. Zero deps.
16
+
17
+ import { realpathSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { basename, extname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { parseSurface, parseSurfaces, SurfaceError } from './lib/surface.mjs';
22
+
23
+ // Above this a count stops being a shape and becomes noise; the true number is printed beside the boxes.
24
+ const MAX_BOXES = 12;
25
+
26
+ const escape = (text) =>
27
+ String(text).replace(
28
+ /[&<>"']/g,
29
+ (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]
30
+ );
31
+
32
+ // A kind whose words are missing still draws its shape: a grey bar where the words would be.
33
+ const words = (block, tag = 'span', cls = 'w') =>
34
+ block.words === null
35
+ ? `<span class="bar"></span>`
36
+ : `<${tag} class="${cls}">${escape(block.words)}</${tag}>`;
37
+
38
+ function boxes(count, cls, fill = () => '<span class="bar"></span>') {
39
+ const drawn = Math.min(count, MAX_BOXES);
40
+ const items = Array.from({ length: drawn }, (_, i) => `<div class="${cls}">${fill(i)}</div>`).join('');
41
+ return items + (count > MAX_BOXES ? `<span class="more">×${count}</span>` : '');
42
+ }
43
+
44
+ const DRAW = {
45
+ head: (b) =>
46
+ `<div class="row">${words(b, 'h2', 'title')}${
47
+ b.action === undefined ? '' : `<span class="button">${escape(b.action)}</span>`
48
+ }</div>`,
49
+ answer: (b) => words(b, 'p', 'answer'),
50
+ summary: (b) => `<div class="row wrap">${boxes(b.count ?? 0, 'stat')}</div>`,
51
+ tiles: (b) =>
52
+ `<div class="grid">${boxes(b.count ?? 0, 'tile', () => '<span class="bar"></span><span class="bar short"></span>')}</div>`,
53
+ steps: (b) =>
54
+ `<div class="row steps">${boxes(b.count ?? 3, 'step', (i) => `<span class="dot">${i + 1}</span>`)}</div>`,
55
+ toolbar: () =>
56
+ '<div class="row"><span class="search">Search</span><span class="pill"></span><span class="pill"></span></div>',
57
+ list: (b) => {
58
+ const columns = b.columns ?? null;
59
+ const cells = columns === null ? 1 : columns.length;
60
+ const head =
61
+ columns === null
62
+ ? ''
63
+ : `<div class="lrow lhead">${columns.map((c) => `<span>${escape(c)}</span>`).join('')}</div>`;
64
+ const row = `<div class="lrow">${'<span><span class="bar"></span></span>'.repeat(cells)}</div>`;
65
+ return `<div class="listbox">${head}${row.repeat(3)}</div>`;
66
+ },
67
+ empty: (b) => `<div class="empty">${words(b)}</div>`,
68
+ card: (b) => `<div class="card">${words(b)}</div>`,
69
+ field: (b) => `<label class="field">${words(b)}<span class="input"></span></label>`,
70
+ tabs: (b) => {
71
+ const labels = b.words === null ? ['', '', ''] : b.words.split('|').map((t) => t.trim());
72
+ return `<div class="row tabs">${labels
73
+ .map(
74
+ (t, i) =>
75
+ `<span class="tab${i === 0 ? ' on' : ''}">${t === '' ? '<span class="bar"></span>' : escape(t)}</span>`
76
+ )
77
+ .join('')}</div>`;
78
+ },
79
+ note: (b) => words(b, 'p', 'note'),
80
+ };
81
+
82
+ const STYLE = `
83
+ *{box-sizing:border-box}body{margin:0;padding:24px 16px;background:#f4f4f4;color:#222;font:14px/1.45 system-ui,sans-serif}
84
+ main{max-width:960px;margin:0 auto}h1{font-size:15px;color:#666;font-weight:600;margin:0 0 16px}
85
+ section{background:#fff;border:1px solid #ccc;border-radius:8px;padding:20px;margin:0 0 28px}
86
+ header{font:12px ui-monospace,monospace;color:#777;border-bottom:1px dashed #ccc;padding-bottom:8px;margin-bottom:16px}
87
+ .block{margin:0 0 14px}.row{display:flex;gap:10px;align-items:center}.wrap{flex-wrap:wrap}
88
+ .title{font-size:22px;margin:0;flex:1}.button{border:1px solid #555;border-radius:6px;padding:6px 12px;background:#e6e6e6;font-weight:600}
89
+ .answer{font-size:16px;font-style:italic;margin:0;color:#333}.note{font-size:12px;color:#777;margin:0}
90
+ .bar{display:inline-block;height:10px;width:120px;max-width:100%;background:#ddd;border-radius:3px}.bar.short{width:60px}
91
+ .stat{border:1px solid #ccc;border-radius:6px;padding:10px;min-width:90px}
92
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px}
93
+ .tile{border:1px solid #ccc;border-radius:6px;padding:16px;display:flex;flex-direction:column;gap:8px;min-height:80px}
94
+ .more{font-size:12px;color:#777;align-self:center}
95
+ .steps .step{display:flex;align-items:center;gap:10px}.steps .step+.step::before{content:"";width:28px;border-top:1px solid #aaa}
96
+ .dot{display:inline-flex;width:26px;height:26px;border-radius:50%;border:1px solid #777;align-items:center;justify-content:center;font-size:12px}
97
+ .search{flex:1;max-width:320px;border:1px solid #bbb;border-radius:6px;padding:6px 10px;color:#999}
98
+ .pill{width:70px;height:26px;border:1px solid #bbb;border-radius:13px}
99
+ .listbox{border:1px solid #ccc;border-radius:6px;overflow:hidden}.lrow{display:flex;border-top:1px solid #e4e4e4}
100
+ .lrow:first-child{border-top:0}.lrow>span{flex:1;min-width:0;padding:10px;min-height:38px}
101
+ .lhead{overflow-wrap:anywhere;background:#eee;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#555;font-weight:600}
102
+ .empty{border:2px dashed #bbb;border-radius:8px;padding:32px;text-align:center;color:#666}
103
+ .card{border:1px solid #bbb;border-radius:8px;padding:16px}
104
+ .field{display:flex;flex-direction:column;gap:6px;max-width:420px}.input{height:34px;border:1px solid #aaa;border-radius:6px;background:#fafafa}
105
+ .tabs{gap:0;border-bottom:1px solid #ccc}.tab{padding:8px 14px;color:#777}.tab.on{color:#222;border-bottom:2px solid #222;font-weight:600}
106
+ `;
107
+
108
+ /** One self-contained grey page, a section per surface. */
109
+ export function renderSketch(surfaces, { title = 'Sketch' } = {}) {
110
+ const sections = surfaces
111
+ .map(
112
+ (surface) =>
113
+ `<section><header>${escape(surface.state)} · ${escape(surface.route)}</header>${surface.blocks
114
+ // `block.kind` is one of the parser's twelve (an own-property check there), so it is safe as an attribute
115
+ // and always has a drawing.
116
+ .map((block) => `<div class="block" data-kind="${block.kind}">${DRAW[block.kind](block)}</div>`)
117
+ .join('\n')}</section>`
118
+ )
119
+ .join('\n');
120
+ return `<!doctype html>
121
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
122
+ <title>${escape(title)}</title><style>${STYLE}</style></head>
123
+ <body><main><h1>${escape(title)} — grey wireframe, ${surfaces.length} state${surfaces.length === 1 ? '' : 's'}</h1>
124
+ ${sections}
125
+ </main></body></html>
126
+ `;
127
+ }
128
+
129
+ const USAGE = 'usage: node scripts/sketch-render.mjs <file.md|file.surface> [--out <file.html>]';
130
+
131
+ export function main(argv) {
132
+ const args = [...argv];
133
+ let out = null;
134
+ let file = null;
135
+ while (args.length > 0) {
136
+ const arg = args.shift();
137
+ if (arg === '--out') {
138
+ out = args.shift() ?? null;
139
+ if (out === null) return { code: 2, stderr: USAGE };
140
+ } else if (arg.startsWith('-') || file !== null) {
141
+ return { code: 2, stderr: `unknown argument \`${arg}\`\n${USAGE}` };
142
+ } else {
143
+ file = arg;
144
+ }
145
+ }
146
+ if (file === null) return { code: 2, stderr: USAGE };
147
+
148
+ const text = readFileSync(file, 'utf8');
149
+ let surfaces;
150
+ try {
151
+ surfaces = extname(file) === '.surface' ? [parseSurface(text, file)] : parseSurfaces(text, file);
152
+ } catch (error) {
153
+ if (error instanceof SurfaceError) return { code: 1, stderr: error.message };
154
+ throw error;
155
+ }
156
+ if (surfaces.length === 0) return { code: 1, stderr: `${file}: no \`surface\` block to draw` };
157
+
158
+ const name = basename(file, extname(file));
159
+ const target = out ?? join(tmpdir(), `sketch-${name}.html`);
160
+ writeFileSync(target, renderSketch(surfaces, { title: `Sketch — ${name}` }));
161
+ return { code: 0, stdout: target };
162
+ }
163
+
164
+ // realpath on both sides: through a symlinked path a plain compare is false and the script exits 0 having done
165
+ // nothing (#189 review).
166
+ const isMain = (() => {
167
+ try {
168
+ return (
169
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
170
+ );
171
+ } catch {
172
+ return false;
173
+ }
174
+ })();
175
+ if (isMain) {
176
+ const { code, stdout, stderr } = main(process.argv.slice(2));
177
+ if (stdout) console.log(stdout);
178
+ if (stderr) console.error(stderr);
179
+ process.exitCode = code;
180
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {