mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
v1.91.230.0 feat(design-shotgun,design-html): archetype handoff and persisted craft exceptions (GSTA-92 phase 2) (#3422)
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * test(touchfiles): design-html-slop-gate selects on the craft check it runs (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GSTA-92) A malformed or missing --accepted exceptions file now gets the full craft check with nothing accepted, after a warning, still exiting 2. Before, it printed nothing, and the craft section treats a missing CRAFT_RESULT as 'check unavailable', so one bad finalized.json switched the gate off (found by both adversarial passes). The header comment and the craft section now document the accepted-count suffix on CRAFT_RESULT. Tests cover duplicate carried-forward exception ids and the slop-gate touchfiles selection. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scope --accepted to the same page; record current counts (GSTA-92) The craft section passed --accepted whenever a finalized.json sat beside the page, so a same-day Start fresh build inherited the old page's exceptions. It now passes them only when evolving or rerunning a page it finalized, records the count the check reports now, and writes [] when nothing was accepted instead of the template's placeholder entry. A test pins exit 2 for a clean page with a broken exceptions file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: note the archetype handoff in the shotgun and design-html loops (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5128ec1469
commit
4814b3d8a4
15 files changed
+219
-39
No files matched your search
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.230.0] - 2026-10-26
|
||||
|
||||
### Changed
|
||||
- **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
|
||||
- **`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: 0 when only accepted findings remain, 1 for unaccepted ones, and 2 for an unknown argument or a missing, unreadable or malformed exceptions file. A bad exceptions file still gets the full check with nothing accepted, after a warning, so a broken record can't switch the craft check off. A finalized.json with no `craft_exceptions` field, written before this change, means no exceptions.
|
||||
|
||||
### Tests
|
||||
- `design-html-slop-gate` is now selected by changes to `lib/design-craft.ts` and `bin/gstack-design-craft` (`test/helpers/touchfiles-data.ts`), which it already exercised but did not list.
|
||||
- `test/design-craft.test.ts` also covers accepted exceptions: count coverage and overflow, duplicate ids carried forward (the highest count wins), a bad exceptions file still checked in full, 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.229.0] - 2026-10-26
|
||||
|
||||
**The OpenSSF Scorecard workflow uploads its results with `github/codeql-action/upload-sarif` 4.39.0 (from 4.38.3), recreated from Dependabot's #3307.** Dependabot's branch can't take a version stamp at merge time, so the same one-line change ships from a crew branch. The new pin `6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee` is the commit that the annotated tag `v4.39.0` points to in github/codeql-action, and GitHub shows that commit as verified. 4.39.0's one breaking change raises the minimum CodeQL bundle to 2.20.7, which applies to `init` and `analyze`. This workflow uses only `upload-sarif` with Scorecard's own SARIF file.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.229.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.230.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+28
-11
@@ -1,20 +1,27 @@
|
||||
#!/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; the
|
||||
// result line then adds " (<k> accepted)" or ", <k> accepted".
|
||||
// Exit 2: usage error, a non-HTML file, or an unreadable or empty file, with
|
||||
// nothing checked; or an unreadable or malformed exceptions file, after a
|
||||
// warning and a full check with no exceptions accepted, so a bad record never
|
||||
// hides the page's tells. 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 +39,16 @@ 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[] = [];
|
||||
let badExceptions = false;
|
||||
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}; checked with none accepted`);
|
||||
badExceptions = true;
|
||||
}
|
||||
}
|
||||
const { issues, accepted } = acceptFindings(checkCraft(source), exceptions);
|
||||
console.log(formatCraft(issues, accepted));
|
||||
process.exit(badExceptions ? 2 : 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.
|
||||
|
||||
@@ -65,7 +65,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker
|
||||
~/.claude/skills/gstack/bin/gstack-design-craft <path-to-finalized.html>
|
||||
```
|
||||
|
||||
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.
|
||||
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)`, plus the accepted count when `--accepted` covers any. 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 at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: 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. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -63,7 +63,9 @@ At Step 4, after the Slop Gate and before the screenshots, run the free checker
|
||||
{{BIN_DIR}}/gstack-design-craft <path-to-finalized.html>
|
||||
```
|
||||
|
||||
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.
|
||||
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)`, plus the accepted count when `--accepted` covers any. 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 at the count it reports now (`[]` when none, never the template's placeholder entry). When evolving a finalized page, or rerunning on the page this run finalized, append `--accepted <that finalized.json>` to the command; never after Start fresh or for a new page, whose old exceptions covered different content: 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. A `cannot use exceptions` warning with exit 2 means that record is malformed: the page was still checked with none accepted, so treat every line as new and write `craft_exceptions` correctly at Step 5.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -462,7 +462,7 @@ You know the feeling. You have a feature, a page, a landing screen... and you're
|
||||
3. It generates 3 distinct design variants as PNGs
|
||||
4. A comparison board opens in your browser with all 3 side-by-side
|
||||
5. You click "Approve" on the one you like, or give feedback for another round
|
||||
6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json`
|
||||
6. The approved variant saves to `~/.gstack/projects/$SLUG/designs/` with an `approved.json`; for a landing, pricing or campaign screen, it also records the page archetype you picked (`classic-hero`, `long-form-story`, `minimal-conversion` or `comparison`) and its section outline
|
||||
|
||||
That `approved.json` is one way to feed `/design-html`. The design pipeline chains: shotgun picks the direction, design-html renders it as working code. But `/design-html` also works with CEO plans, design reviews, or just a description.
|
||||
|
||||
@@ -512,7 +512,7 @@ Not every page needs the full Pretext engine. The skill reads the design and pic
|
||||
|
||||
### The refinement loop
|
||||
|
||||
1. Reads the approved mockup from `approved.json`
|
||||
1. Reads the approved mockup from `approved.json`, plus any page archetype and section outline you picked on the board, which it builds as given instead of asking again
|
||||
2. Uses GPT-4o vision to extract implementation spec (colors, typography, layout)
|
||||
3. Generates self-contained HTML with Pretext inlined (15KB, zero network dependency)
|
||||
4. Spins up a live-reload server so you see changes instantly
|
||||
|
||||
+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');
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.229",
|
||||
"version": "1.91.230",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+107
-3
@@ -2,8 +2,10 @@ 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';
|
||||
import { selectTests } from './helpers/test-selection';
|
||||
import { E2E_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
|
||||
@@ -21,8 +23,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 +118,105 @@ 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)');
|
||||
const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }];
|
||||
for (const list of [carried, [...carried].reverse()]) {
|
||||
expect(acceptFindings([{ id: 'round-number', count: 3, fix: 'use the supplied figure' }], list)).toEqual({ issues: [], accepted: [{ id: 'round-number', count: 3, reason: 'supplied figures' }] });
|
||||
}
|
||||
});
|
||||
|
||||
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 after a full check with none accepted', () => {
|
||||
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"}]}');
|
||||
fs.writeFileSync(path.join(dir, 'broken.json'), '{');
|
||||
for (const name of ['bad.json', 'broken.json', 'missing.json']) {
|
||||
const bad = run(page, '--accepted', path.join(dir, name));
|
||||
expect(bad).toMatchObject({ code: 2, out: run(page).out });
|
||||
expect(bad.err).toContain('checked with none accepted');
|
||||
}
|
||||
fs.writeFileSync(page, CLEAN);
|
||||
expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' });
|
||||
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('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => {
|
||||
for (const file of ['lib/design-craft.ts', 'bin/gstack-design-craft']) {
|
||||
expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate');
|
||||
}
|
||||
});
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
@@ -856,7 +856,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
|
||||
// /diagram (diagram-render bundle consumers). Triplet = deterministic
|
||||
// functional (gate); authoring quality = LLM-judged benchmark (periodic).
|
||||
|
||||
Reference in new issue
Block a user