diff --git a/CHANGELOG.md b/CHANGELOG.md index 908f07c02..f7a1cebf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,18 @@ - **/design-html reads `sections/craft.md` on every run (Step 1).** The spec opens with `Surface: Persuade` or `Surface: other`. A Persuade page (landing, pricing, waitlist, campaign, comparison) asks at most three multiple-choice questions (primary action, traffic source, published or reference) and states the rest in one `Assumed:` line; picks one of four archetypes with a reason; keeps the hero budget (one proof signal only when real proof exists, never a metric row) and chooses the rest of the page from the archetype and objections; gives the primary action real invalid, pending, failure and success states. Every page: claims (testimonials, logos, results, prices, guarantees) come only from the user, and missing proof is left out or marked `[Needs proof: …]`; illustrative data looks real; a ship floor scoped to full documents (title, description, favicon, share tags on shareable pages, `og:image` only from a supplied URL, a skip link that reaches `
`, landmarks, alt text, inline validation, no dead links, `aria-current` on route navigation); scale snapping with readability floors and the nested-radius rule; balanced line breaks; motion rules for the one authored moment (content visible without script, `IntersectionObserver`, cheap properties only, no `scale(0)`, named curves, `100dvh`). - **Step 4** runs `bin/gstack-design-craft` after the Slop Gate, whether or not the detector is installed, with the gate's contract: one fix pass, one rerun, the rest accepted with a reason, once more when the refinement loop ends. The hand-off opens with a "Before this can ship" block: proof markers, production dependencies, exceptions, and every claim the check changed. - **/design-shotgun**: a landing, pricing or campaign screen's variants also differ in page archetype, keep the one primary action identical, and carry their section outline as a caption. +- **The archetype the user picks on the /design-shotgun board now reaches /design-html.** Captions name the archetype id (`classic-hero`, `long-form-story`, `minimal-conversion`, `comparison`). On approval, approved.json also gets the chosen `archetype` and its `sections` outline, added with the Edit tool so no free text reaches a shell command. /design-html reads both in Case A, and the craft section uses them as given instead of choosing again or asking. +- **Accepted craft exceptions persist.** /design-html's finalized.json records each accepted `CRAFT_CHECK` finding in `craft_exceptions` (`id`, `count`, `reason`) and carries forward earlier ones. A later run passes that file with `--accepted`, so a finding the user already accepted is reported as accepted instead of flagged again. A higher count is still flagged as a new issue. ### Added - **`bin/gstack-design-craft`** (`lib/design-craft.ts`): prints `CRAFT_CHECK: ` per tell and `CRAFT_RESULT: clean` (exit 0) or ` issue(s)` (exit 1); exit 2 on a non-HTML, empty or unreadable file. Copy rules read decoded visible text; `alert()` counts only in scripts; head and navigation rules run only on a full document. Buzzwords report under the catalog's `marketing-buzzword` id. +- **`gstack-design-craft --accepted `**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: ` and doesn't fail the check. `CRAFT_RESULT` adds the accepted count. Exit codes are unchanged: 0 when only accepted findings remain, 1 for unaccepted ones, and 2 for a missing, unreadable or malformed exceptions file or an unknown argument. A finalized.json with no `craft_exceptions` field, written before this change, means no exceptions. - **NOTICE.md** and `licenses/MIT-elaya.txt`. Not carried over, because they conflict with gstack's design rules: Elaya's fixed fonts (Geist and Poppins are on gstack's overused-display list), palette, spacing values and icon sets, gradient hero text, mandatory scroll reveals and tagline section, and the island nav. ### Tests - `test/design-craft.test.ts`: every tell with its count; a finished page, inlined Pretext, a `100vh` line with its `100dvh` fallback, discount copy and in-page anchors stay clean; look-alike attributes, encoded markers, `!important` and reversed fallbacks; skip links judged by position and target in any language; the craft section's banned words and names all flagged; CLI exit codes. - `design-html-slop-gate` also requires the craft check to run once or twice; the design-html carve guard requires the craft section read. +- `test/design-craft.test.ts` also covers accepted exceptions: count coverage and overflow, the finalized.json block in design-html/SKILL.md parsed by the same reader the CLI uses, rejected malformed lists, CLI exit codes with `--accepted`, and the archetype handoff contract (the same four ids in /design-shotgun and the craft section, the approved.json step, Case A and the craft section's no-re-ask rule). ## [1.91.218.0] - 2026-10-25 diff --git a/bin/gstack-design-craft b/bin/gstack-design-craft index 50c01cb9f..54b93e5a2 100755 --- a/bin/gstack-design-craft +++ b/bin/gstack-design-craft @@ -1,20 +1,24 @@ #!/usr/bin/env -S bun --no-env-file --config=/dev/null // gstack-design-craft — free craft check for a generated page (/design-html Step 4). // -// Usage: gstack-design-craft +// Usage: gstack-design-craft [--accepted ] // // Prints one "CRAFT_CHECK: " line per tell, then // "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: issue(s)" (exit 1). -// Exit 2: usage error, a non-HTML file, an unreadable or empty file. Head and -// navigation rules run only on a full HTML document; a fragment gets the copy -// and code rules alone. +// --accepted reads the craft_exceptions a finalized.json recorded: a tell an +// exception covers (same id, count at most the accepted count) prints as +// "CRAFT_ACCEPTED: " and does not fail the check. +// Exit 2: usage error, a non-HTML file, an unreadable or empty file, or an +// unreadable or malformed exceptions file. Head and navigation rules run only +// on a full HTML document; a fragment gets the copy and code rules alone. import { readFileSync } from 'fs'; -import { checkCraft, formatCraft } from '../lib/design-craft'; +import { acceptFindings, checkCraft, formatCraft, parseCraftExceptions, type CraftException } from '../lib/design-craft'; -const file = process.argv[2]; -if (!file) { - console.error('Usage: gstack-design-craft '); +const USAGE = 'Usage: gstack-design-craft [--accepted ]'; +const [file, flag, acceptedFile, ...rest] = process.argv.slice(2); +if (!file || (flag !== undefined && (flag !== '--accepted' || !acceptedFile)) || rest.length > 0) { + console.error(USAGE); process.exit(2); } if (!/\.html?$/i.test(file)) { @@ -32,6 +36,15 @@ if (source.trim() === '') { console.error(`gstack-design-craft: ${file} is empty; nothing was checked`); process.exit(2); } -const findings = checkCraft(source); -console.log(formatCraft(findings)); -process.exit(findings.length === 0 ? 0 : 1); +let exceptions: CraftException[] = []; +if (acceptedFile) { + try { + exceptions = parseCraftExceptions(readFileSync(acceptedFile, 'utf-8')); + } catch (err) { + console.error(`gstack-design-craft: cannot use exceptions from ${acceptedFile}: ${(err as Error).message}`); + process.exit(2); + } +} +const { issues, accepted } = acceptFindings(checkCraft(source), exceptions); +console.log(formatCraft(issues, accepted)); +process.exit(issues.length === 0 ? 0 : 1); diff --git a/design-html/SKILL.md b/design-html/SKILL.md index 3f33d4f00..fa2c85ac8 100644 --- a/design-html/SKILL.md +++ b/design-html/SKILL.md @@ -462,7 +462,8 @@ Now route based on what was found. Check these cases in order: ### Case A: approved.json exists (design-shotgun ran) -If `APPROVED` was found, read it for user feedback and screen name, and resolve the +If `APPROVED` was found, read it for user feedback, screen name, and any `archetype` +and `sections` (the craft section takes them as given), and resolve the approved PNG (never guess it from a letter or the newest variant): ```bash @@ -731,7 +732,7 @@ _DJ=$(mktemp "${TMPDIR:-/tmp}/gstack-detect.XXXXXX"); bun --no-env-file --config Exit 2 → one surgical fix pass over the non-advisory rules in the `DETECT_TOP` block, then scan once more. Whatever remains, present the page with those findings listed as accepted-with-reason: a pattern the approved mockup contains, a value DESIGN.md's tokens bless or a pattern its Decisions Log or Do's and Don'ts records as intentional, or an inline `` the user agreed to. One pass, not a loop. Any other first line from the probe: skip, no ceremony. -Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft `, one fix pass and one rerun, as above. +Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft ` (plus `--accepted` per the section), one fix pass and one rerun, as above. ### Verification Screenshots @@ -832,6 +833,7 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "", "framework": "", "iterations": , + "craft_exceptions": [{"id": "", "count": , "reason": ""}], "date": "", "screen": "", "branch": "" diff --git a/design-html/SKILL.md.tmpl b/design-html/SKILL.md.tmpl index 998782370..80d425437 100644 --- a/design-html/SKILL.md.tmpl +++ b/design-html/SKILL.md.tmpl @@ -82,7 +82,8 @@ Now route based on what was found. Check these cases in order: ### Case A: approved.json exists (design-shotgun ran) -If `APPROVED` was found, read it for user feedback and screen name, and resolve the +If `APPROVED` was found, read it for user feedback, screen name, and any `archetype` +and `sections` (the craft section takes them as given), and resolve the approved PNG (never guess it from a letter or the newest variant): ```bash @@ -341,7 +342,7 @@ kill 2>/dev/null || true {{DESIGN_DETECTOR:gate}} -Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft `, one fix pass and one rerun, as above. +Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft ` (plus `--accepted` per the section), one fix pass and one rerun, as above. ### Verification Screenshots @@ -442,6 +443,7 @@ Write `finalized.json` alongside the HTML: "pretext_tier": "", "framework": "", "iterations": , + "craft_exceptions": [{"id": "", "count": , "reason": ""}], "date": "", "screen": "", "branch": "" diff --git a/design-html/sections/craft.md b/design-html/sections/craft.md index 1630f7fb9..8d23c0c4c 100644 --- a/design-html/sections/craft.md +++ b/design-html/sections/craft.md @@ -13,7 +13,7 @@ - who it is for, the problem they came with, their top three objections, what they already know - the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers -**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y"). +**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y"). **Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison. @@ -67,6 +67,8 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports. Whenever a finalized.json already sits beside the page, or beside the one you are evolving, append `--accepted ` to the command: a tell it covers prints `CRAFT_ACCEPTED: ` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. + **Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md. diff --git a/design-html/sections/craft.md.tmpl b/design-html/sections/craft.md.tmpl index e6119d84d..1ccd4985c 100644 --- a/design-html/sections/craft.md.tmpl +++ b/design-html/sections/craft.md.tmpl @@ -11,7 +11,7 @@ - who it is for, the problem they came with, their top three objections, what they already know - the proof the user can supply (named customers, attributed quotes, real numbers) and the risk terms the business offers -**Archetype.** Pick one and say why in the spec's layout type: **classic hero + sections** (default; the product reads from one screenshot), **long-form story** (the visitor needs educating or is skeptical), **minimal conversion** (high-intent traffic or a small offer), **comparison** (search intent is "X vs Y"). +**Archetype.** When approved.json carries an `archetype`, the user already chose it on the /design-shotgun board: take it and its `sections` outline as given, name both in the spec's layout type, and do not re-pick or re-ask. Otherwise pick one and say why there: **classic hero + sections** (`classic-hero`, default; the product reads from one screenshot), **long-form story** (`long-form-story`, the visitor needs educating or is skeptical), **minimal conversion** (`minimal-conversion`, high-intent traffic or a small offer), **comparison** (`comparison`, search intent is "X vs Y"). **Structure.** Above the fold at 375×667 and on desktop, inside the hero budget: a headline naming outcome and audience, one sentence on how, one primary CTA, one proof signal only when real proof exists (named logos or one attributed quote; a metric row stays banned), one product visual. Below it, choose modules and order from the archetype and the objections. The long-form menu: problem → solution, three to five benefits, how it works in three steps, proof, an FAQ of six to twelve real objections, the risk reversal, a final CTA identical to the top. A minimal page stays minimal; a comparison page leads with the comparison. @@ -65,6 +65,8 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker It prints one `CRAFT_CHECK: ` line per tell (the realism rules above, `[Needs proof:` markers, scroll listeners, `100vh`, `transition: all`, `href="#"`, `alert()`, missing alt; on a full document a missing title, description, favicon, working skip link, or `aria-current` on route navigation), then `CRAFT_RESULT: clean` or `CRAFT_RESULT: issue(s)`. No `CRAFT_RESULT` line: say the check was unavailable and continue. Same contract as the Slop Gate: one surgical fix pass, one rerun, the rest listed as accepted-with-reason (a real customer named Nexus, a supplied 50% figure). Rerun once when the refinement loop exits; report only new lines. +**Accepted exceptions persist.** Step 5's finalized.json records each one in `craft_exceptions` as `{ id, count, reason }`, carrying forward earlier ones the check still reports. Whenever a finalized.json already sits beside the page, or beside the one you are evolving, append `--accepted ` to the command: a tell it covers prints `CRAFT_ACCEPTED: ` and does not fail the check, so name those as accepted earlier instead of fixing or re-flagging them. A count above the accepted one stays a `CRAFT_CHECK` line; it is new, so fix it or accept the new count with a reason. + **Before this can ship.** Open the Step 4 hand-off, and repeat before Step 5's "Copy to project": every `[Needs proof: …]` marker, the dependencies the page cannot own (legal URLs, form endpoint, og:image, indexing, analytics and cookie consent where required), the accepted exceptions, and every claim the check made you change. Adapted from Elaya's [ai-design-skills](https://github.com/elayadesign/ai-design-skills) and [redesign-skill](https://github.com/elayadesign/redesign-skill) (MIT, © 2026 Elaya), rewritten for gstack. See NOTICE.md. diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index cc9703d6b..2e7608b4c 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -634,9 +634,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis exists, it decides what varies: keep its fonts and palette and vary layout and composition. Without one, vary font family, color palette, and layout approach. If two variants look like siblings, rework the weaker one with a deliberately different direction. -For a landing, pricing or campaign screen, also vary the page archetype (classic hero, -long-form story, minimal conversion, comparison), keep the one primary action identical, -and caption each variant with its section outline. +For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`, +`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action +identical, and caption each variant with its archetype id and section outline. Concrete test: if someone could swap the headline text between two variants without noticing, they're too similar. Variants should feel like separate design teams made @@ -880,7 +880,9 @@ Use AskUserQuestion to confirm before saving. ## Step 6: Save & Next Steps -Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). +Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants +varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline +as an array of section names) to it with the Edit tool; /design-html takes them as given. If invoked from another skill: return the structured feedback for that skill to consume. The calling skill reads `approved.json` and resolves the approved image with diff --git a/design-shotgun/SKILL.md.tmpl b/design-shotgun/SKILL.md.tmpl index a037184ad..ac85e05c4 100644 --- a/design-shotgun/SKILL.md.tmpl +++ b/design-shotgun/SKILL.md.tmpl @@ -219,9 +219,9 @@ Draw on DESIGN.md, taste memory, and the user's request to make each concept dis exists, it decides what varies: keep its fonts and palette and vary layout and composition. Without one, vary font family, color palette, and layout approach. If two variants look like siblings, rework the weaker one with a deliberately different direction. -For a landing, pricing or campaign screen, also vary the page archetype (classic hero, -long-form story, minimal conversion, comparison), keep the one primary action identical, -and caption each variant with its section outline. +For a landing, pricing or campaign screen, also vary the page archetype (`classic-hero`, +`long-form-story`, `minimal-conversion`, `comparison`), keep the one primary action +identical, and caption each variant with its archetype id and section outline. Concrete test: if someone could swap the headline text between two variants without noticing, they're too similar. Variants should feel like separate design teams made @@ -286,7 +286,9 @@ Use AskUserQuestion to confirm before saving. ## Step 6: Save & Next Steps -Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). +Write `approved.json` to `$_DESIGN_DIR/` (handled by the loop above). When the variants +varied archetype, then add the chosen variant's `archetype` id and `sections` (its outline +as an array of section names) to it with the Edit tool; /design-html takes them as given. If invoked from another skill: return the structured feedback for that skill to consume. The calling skill reads `approved.json` and resolves the approved image with diff --git a/lib/design-craft.ts b/lib/design-craft.ts index 94198739f..faa5a12dc 100644 --- a/lib/design-craft.ts +++ b/lib/design-craft.ts @@ -103,8 +103,42 @@ export function checkCraft(html: string): CraftFinding[] { return findings; } -export function formatCraft(findings: CraftFinding[]): string { - const lines = findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`); - lines.push(findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`); +/** An accepted finding recorded in finalized.json's `craft_exceptions`. */ +export interface CraftException { + id: string; + count: number; + reason: string; +} + +/** Reads `craft_exceptions` from a finalized.json; a record without one has none. Throws on a malformed record. */ +export function parseCraftExceptions(json: string): CraftException[] { + const record = JSON.parse(json); + if (typeof record !== 'object' || record === null || Array.isArray(record)) throw new Error('not a finalized.json object'); + const list = record.craft_exceptions ?? []; + const valid = (e: any) => typeof e?.id === 'string' && Number.isInteger(e.count) && e.count > 0 && typeof e.reason === 'string' && e.reason.trim() !== ''; + if (!Array.isArray(list) || !list.every(valid)) throw new Error('craft_exceptions must be a list of { id, count, reason } with a positive count and a reason'); + return list.map(({ id, count, reason }) => ({ id, count, reason: reason.replace(/\s+/g, ' ').trim() })); +} + +/** A finding is accepted when an exception names its id with at least its count; a higher count stays an issue and says how many were accepted. */ +export function acceptFindings(findings: CraftFinding[], exceptions: CraftException[]): { issues: CraftFinding[]; accepted: CraftException[] } { + const issues: CraftFinding[] = []; + const accepted: CraftException[] = []; + for (const finding of findings) { + const exception = exceptions.filter(e => e.id === finding.id).sort((a, b) => b.count - a.count)[0]; + if (!exception) issues.push(finding); + else if (finding.count <= exception.count) accepted.push({ id: finding.id, count: finding.count, reason: exception.reason }); + else issues.push({ ...finding, fix: `${finding.fix} (${exception.count} accepted earlier: ${exception.reason})` }); + } + return { issues, accepted }; +} + +export function formatCraft(findings: CraftFinding[], accepted: CraftException[] = []): string { + const lines = [ + ...findings.map(f => `CRAFT_CHECK: ${f.id} ${f.count} ${f.fix}`), + ...accepted.map(a => `CRAFT_ACCEPTED: ${a.id} ${a.count} ${a.reason}`), + ]; + const note = accepted.length === 0 ? '' : findings.length === 0 ? ` (${accepted.length} accepted)` : `, ${accepted.length} accepted`; + lines.push(`${findings.length === 0 ? 'CRAFT_RESULT: clean' : `CRAFT_RESULT: ${findings.length} issue(s)`}${note}`); return lines.join('\n'); } diff --git a/test/design-craft.test.ts b/test/design-craft.test.ts index d6d832f3b..a1678f9bf 100644 --- a/test/design-craft.test.ts +++ b/test/design-craft.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { BUZZWORDS, checkCraft, formatCraft, visibleText } from '../lib/design-craft'; +import { acceptFindings, BUZZWORDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft'; import { catalogEntry } from '../lib/design-catalog'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -21,8 +21,8 @@ const CLEAN = `Ledgerline: close the <main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main> <script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`; -function run(file?: string) { - const r = Bun.spawnSync([BIN, ...(file ? [file] : [])], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); +function run(file?: string, ...extra: string[]) { + const r = Bun.spawnSync([BIN, ...(file ? [file] : []), ...extra], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; } @@ -116,3 +116,91 @@ describe('design-craft check', () => { } }); }); + +describe('accepted craft exceptions (finalized.json craft_exceptions)', () => { + test('an exception covers its id up to the accepted count; a higher count stays an issue and names the accepted count', () => { + const findings = checkCraft(DIRTY); + const { issues, accepted } = acceptFindings(findings, [ + { id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, + { id: 'round-number', count: 2, reason: 'supplied figures' }, + { id: 'scroll-listener', count: 5, reason: 'unused' }, + ]); + expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]); + expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)'); + expect(issues).toHaveLength(findings.length - 2); + expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)'); + }); + + test('parseCraftExceptions reads the finalized.json design-html writes and rejects a malformed list', () => { + const skill = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8'); + const block = skill.slice(skill.indexOf('Write `finalized.json` alongside the HTML')).match(/```json\n([\s\S]*?)```/)![1]; + const record = JSON.parse(block.replace(/<number of refinement iterations>|<n>/g, '1')); + expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]); + expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]); + expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]); + for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) { + expect(() => parseCraftExceptions(bad)).toThrow(); + } + }); + + test('the CLI reports accepted tells as CRAFT_ACCEPTED lines: exit 0 when only accepted ones remain, 1 for the rest, 2 on a bad exceptions file', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-accepted-')); + try { + const page = path.join(dir, 'finalized.html'); + const record = path.join(dir, 'finalized.json'); + fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme')); + fs.writeFileSync(record, JSON.stringify({ mode: 'evolve', craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme Freight is the customer' }] })); + expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_ACCEPTED: placeholder-brand 1 Acme Freight is the customer\nCRAFT_RESULT: clean (1 accepted)\n' }); + expect(run(page).code).toBe(1); + fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus')); + const twice = run(page, '--accepted', record); + expect(twice.code).toBe(1); + expect(twice.out).toBe('CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data (1 accepted earlier: Acme Freight is the customer)\nCRAFT_RESULT: 1 issue(s)\n'); + fs.writeFileSync(page, DIRTY); + const dirty = run(page, '--accepted', record); + expect(dirty.code).toBe(1); + expect(dirty.out).toContain('CRAFT_ACCEPTED: placeholder-brand 1 '); + expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 17 issue(s), 1 accepted'); + fs.writeFileSync(path.join(dir, 'old.json'), JSON.stringify({ mode: 'freeform' })); + expect(run(page, '--accepted', path.join(dir, 'old.json')).out).toBe(run(page).out); + fs.writeFileSync(path.join(dir, 'bad.json'), '{"craft_exceptions":[{"id":"oops"}]}'); + expect(run(page, '--accepted', path.join(dir, 'bad.json'))).toMatchObject({ code: 2, out: '' }); + fs.writeFileSync(path.join(dir, 'broken.json'), '{'); + expect(run(page, '--accepted', path.join(dir, 'broken.json')).code).toBe(2); + expect(run(page, '--accepted', path.join(dir, 'missing.json')).code).toBe(2); + expect(run(page, '--accepted').code).toBe(2); + expect(run(page, '--strict').code).toBe(2); + expect(run(page, '--accepted', record, 'extra').code).toBe(2); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('design-shotgun to design-html handoff', () => { + const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison']; + const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8'); + + test('design-shotgun records the chosen archetype and outline in approved.json, and design-html takes them as given', () => { + const shotgun = read('design-shotgun', 'SKILL.md'); + const craft = read('design-html', 'sections', 'craft.md'); + const html = read('design-html', 'SKILL.md'); + for (const id of ARCHETYPES) { + expect(shotgun).toContain(`\`${id}\``); + expect(craft).toContain(`\`${id}\``); + } + const save = shotgun.slice(shotgun.indexOf('## Step 6: Save & Next Steps')); + expect(save).toMatch(/add the chosen variant's `archetype` id and `sections`[^.]*to it with the Edit tool/); + expect(html.slice(html.indexOf('### Case A'), html.indexOf('### Case B'))).toContain('any `archetype`\nand `sections`'); + expect(craft).toMatch(/When approved\.json carries an `archetype`[^.]*: take it and its `sections` outline as given[^.]*do not re-pick or re-ask/); + }); + + test('design-html persists accepted exceptions and passes them back with --accepted', () => { + const html = read('design-html', 'SKILL.md'); + const craft = read('design-html', 'sections', 'craft.md'); + expect(html).toMatch(/gstack-design-craft <path-to-finalized\.html>` \(plus `--accepted` per the section\)/); + expect(craft).toContain('records each one in `craft_exceptions`'); + expect(craft).toContain('append `--accepted <that finalized.json>` to the command'); + expect(craft).toContain('CRAFT_ACCEPTED: <id> <count> <reason>'); + }); +});