mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
feat(design-shotgun,design-html): carry the chosen archetype into approved.json; persist accepted craft exceptions in finalized.json (GSTA-92 phase 2)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
975e836382
commit
98316193c6
10 files changed
+182
-31
No files matched your search
@@ -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 `<main>`, 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: <id> <count> <fix>` per tell and `CRAFT_RESULT: clean` (exit 0) or `<n> 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 <finalized.json>`**: a finding whose id an exception covers, at no more than the accepted count, prints `CRAFT_ACCEPTED: <id> <count> <reason>` 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
|
||||
|
||||
|
||||
+24
-11
@@ -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 <file.html>
|
||||
// Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]
|
||||
//
|
||||
// Prints one "CRAFT_CHECK: <id> <count> <fix>" line per tell, then
|
||||
// "CRAFT_RESULT: clean" (exit 0) or "CRAFT_RESULT: <n> 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: <id> <count> <reason>" 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 <file.html>');
|
||||
const USAGE = 'Usage: gstack-design-craft <file.html> [--accepted <finalized.json>]';
|
||||
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);
|
||||
@@ -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 `<!-- impeccable-disable <rule>: <reason> -->` 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 <path-to-finalized.html>`, one fix pass and one rerun, as above.
|
||||
Then, always: the craft section's check, `~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>` (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": "<selected tier>",
|
||||
"framework": "<vanilla|react|svelte|vue>",
|
||||
"iterations": <number of refinement iterations>,
|
||||
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
|
||||
"date": "<ISO 8601>",
|
||||
"screen": "<screen name>",
|
||||
"branch": "<current branch>"
|
||||
|
||||
@@ -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 <PID> 2>/dev/null || true
|
||||
|
||||
{{DESIGN_DETECTOR:gate}}
|
||||
|
||||
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>`, one fix pass and one rerun, as above.
|
||||
Then, always: the craft section's check, `{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>` (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": "<selected tier>",
|
||||
"framework": "<vanilla|react|svelte|vue>",
|
||||
"iterations": <number of refinement iterations>,
|
||||
"craft_exceptions": [{"id": "<craft id>", "count": <n>, "reason": "<why accepted>"}],
|
||||
"date": "<ISO 8601>",
|
||||
"screen": "<screen name>",
|
||||
"branch": "<current branch>"
|
||||
|
||||
@@ -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: <id> <count> <fix>` 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: <n> 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 <that finalized.json>` to the command: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` 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.
|
||||
@@ -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: <id> <count> <fix>` 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: <n> 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 <that finalized.json>` to the command: a tell it covers prints `CRAFT_ACCEPTED: <id> <count> <reason>` 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
-3
@@ -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');
|
||||
}
|
||||
@@ -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 = `<!doctype html><html lang="en"><head><title>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>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user