diff --git a/.agents/skills/stack/SKILL.md b/.agents/skills/stack/SKILL.md index e41423d..066e7bf 100644 --- a/.agents/skills/stack/SKILL.md +++ b/.agents/skills/stack/SKILL.md @@ -65,9 +65,35 @@ cover at least: - **Supply-chain floor**, as soon as the stack has dependencies: an SBOM (software bill of materials) of at least top-level dependencies - the CRA legal floor - plus dependency audit and license scan, all wired into CI with this ecosystem's current tools. + - **Design detector**, when the product has a user interface: a CI stage that runs the design + method's own detector over the surfaces this project ships, beside the typecheck and the + tests. It is deterministic, model-free and needs no key, so it belongs with the mechanical + gates rather than with the design skill. Give the stage a runner that meets the method's + stated `engines.node`, which can be higher than the one the rest of CI uses: + + ```yaml + - name: The shipped surfaces carry none of the tells this framework refuses + run: npx -y impeccable@latest detect + ``` + + No `continue-on-error` and no fallback: a detector that cannot install is a red job, because + a green tick standing for a scan that never ran is worse than no scan. `stack-gates` reads + this stage and counts the detector as wired only when a workflow actually runs it. The edit + hook needs nothing here: it is installed with the payload by + `node checks/design-method.mjs --install`, and it reports while the code is being written. + Run the CI command itself to reproduce a CI finding locally. Measured on 2026-08-07: the + detector bundled with the installed payload reports less than the published CLI on the same + file (9 findings against 0 on this repo's own page), so the hook's silence is not the gate's + verdict. - Add this ecosystem's file extensions to `extraTextExtensions`/`extraCodeExtensions` in `checks/config.json` so the denylist/secrets/zombie checks cover the product code. -- Add the chosen tools' commands to the stack standards file so any agent can run them. +- Add the chosen tools' commands to the stack standards file so any agent can run them. Where + the detector is wired, that file also says what it looks at (the tells a rendered interface + gives away: type, layout, color, motion, contrast, design-system drift) and how a false + positive is retired: the narrowest exception that fits, recorded with its reason in + `.impeccable/config.json` through the method's own `hooks ignore-value` command, never by + editing the config by hand and never by widening the rule off the whole project. A finding + nobody examined is not a false positive. - Prove the gates work: introduce a deliberate violation, watch the gate fail, revert. An untested gate is false confidence. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cb8e22..3cf8838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,31 @@ jobs: # - name: Secret scan (gitleaks or equivalent) # - name: Build + # The first mechanical check this framework has ever had on what an interface renders + # (spec 011). The design method is installed per project and gitignored like a dependency, so + # this job fetches it at its current release instead of reading it out of the tree. A project + # on Groundwork with no interface deletes this job; one with more surfaces adds them to the + # scan. `stack-gates` reads this stage: while .impeccable/config.json declares the method, a + # workflow has to actually run the detector. + design: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + # The design method's own floor (its `engines.node`: 22.12 or newer at v4.0.4). The + # jobs beside it stay on 20; this one does not settle that question for them. + node-version: 22 + + # index.html is what this repository publishes. docs/design/reference/ stays out of the + # scan on purpose: each of its pages mimics one UI library's look so the owner can compare + # foundations, so a tell there is the file doing its job. Findings that were examined and + # accepted live with their reason in .impeccable/config.json, which this run reads. + # Nothing here falls back to green: a detector that cannot install fails the job, because + # a green tick standing for a scan that never ran is worse than no scan at all. + - name: The published page carries none of the tells this framework refuses + run: npx -y impeccable@latest detect index.html + # Every adoption claim Groundwork makes rests on one walk: a fresh copy reaches a governed # first commit without the maintainer. Proving it once by hand dates the evidence; proving it # on every push means the green tick beside a commit is the claim, and a broken copy route diff --git a/.gitignore b/.gitignore index 25f7d28..1456aab 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ Thumbs.db CLAUDE.local.md *.local.md .claude/scheduled_tasks.lock +# Where the design method's edit hook is wired for this machine. It names a path inside the +# gitignored payload, so a shared copy of it would point every clone at a file it does not have. +.claude/settings.local.json .idea/ .vscode/* !.vscode/settings.json diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..94fea2e --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,44 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "overused-font", + "value": "inter", + "files": [ + "index.html" + ], + "createdAt": "2026-08-07T19:36:56.195Z", + "reason": "Owner confirmed 2026-08-07: Inter is the explainer's body face by choice. The picker swaps the display face only, and both faces are self-hosted so the page fetches nothing." + }, + { + "rule": "marquee", + "value": "*", + "files": [ + "index.html" + ], + "createdAt": "2026-08-07T19:36:56.224Z", + "reason": "Owner confirmed 2026-08-07: the tool band is decorative (aria-hidden), pauses on hover, and is disabled under reduced motion." + }, + { + "rule": "cramped-padding", + "value": "*", + "files": [ + "index.html" + ], + "createdAt": "2026-08-07T19:52:47.881Z", + "reason": "Measured false positive 2026-08-07: the three hits are .stat cells whose padding-left:0 always travels with border-left:0 (.stat:first-child, and :nth-child(odd) plus :last-child below 760px). The static engine merges those selectors and reads a zero inset against a border that cell does not have. Reproduced in a five-line fixture, and absent as soon as the pairing is removed." + }, + { + "rule": "flat-type-hierarchy", + "value": "*", + "files": [ + "index.html" + ], + "createdAt": "2026-08-07T19:52:47.911Z", + "reason": "Owner confirmed 2026-08-07: the rule wants roughly 1.25x between every step, measured on a 12.5/14/16 fixture that still fires, so passing it means running this page on two body sizes. It is dense UI (stat labels, code chips, captions, sheet hints) and that ramp would flatten those distinctions into body text." + } + ] + } +} diff --git a/checks/check-stack.mjs b/checks/check-stack.mjs index a8f444b..cf14052 100644 --- a/checks/check-stack.mjs +++ b/checks/check-stack.mjs @@ -20,29 +20,50 @@ const stackFiles = (standards) => readdirSync(standards, { withFileTypes: true } && e.name !== 'GLOBAL.md' && !e.name.startsWith('TEMPLATE-')) .map((e) => e.name); +// A line that runs the design detector, as opposed to one that talks about it. Comments are +// excluded on purpose: a commented stage is the exact state this gate exists to catch, and it is +// how a workflow claims a check it never performs. +const runsDetector = (line) => !/^\s*#/.test(line) && /impeccable/i.test(line) && /\bdetect\b/.test(line); + export const stackChecks = ({ root, fail, lines }) => ({ 'stack-gates'() { - const standards = join(root, 'docs', 'standards'); - if (!existsSync(standards)) return; - const stacks = stackFiles(standards); - if (!stacks.length) return; - // Another CI host is explicitly allowed (`stack` section 3: "or this host's equivalent"), // and whether CI exists at all is enforcement.mjs's report to make. One fact, one place. const wfDir = join(root, '.github', 'workflows'); if (!existsSync(wfDir)) return; + const workflows = readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n)); - for (const name of readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n))) { - lines(join(wfDir, name)).forEach((line, i) => { - if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return; - fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`); - }); + const standards = join(root, 'docs', 'standards'); + const stacks = existsSync(standards) ? stackFiles(standards) : []; + if (stacks.length) { + for (const name of workflows) { + lines(join(wfDir, name)).forEach((line, i) => { + if (!/^\s*#\s*(-\s*name:|---\s*Stack gates)/.test(line)) return; + fail(`.github/workflows/${name}:${i + 1} still carries a commented-out stack gate while docs/standards/ names a stack (${stacks.join(', ')}). Until that stage is filled in, CI proves Groundwork's own rules and nothing about this project's code. Replace the placeholders with this stack's real gates per the skill \`stack\` section 3, and delete the ones this stack has no equivalent for instead of leaving them commented.`); + }); + } + } + + // The design method's half of the same window. The detector is the first mechanical check + // this framework has on what an interface renders (spec 011), and it is the one gate whose + // payload is deliberately absent from a clone: it is gitignored like a dependency. So the + // question "does this project have an interface it judges with the method" is answered by + // the tracked artifact the method writes, never by looking for the payload on disk. + if (!existsSync(join(root, '.impeccable', 'config.json'))) return; + const wired = workflows.some((name) => lines(join(wfDir, name)).some(runsDetector)); + if (!wired) { + fail(`.impeccable/config.json declares the design method for this project, but no workflow in .github/workflows/ runs its detector, so nothing mechanical looks at what this interface renders. Add the stage per the skill \`stack\` section 3 (\`npx -y impeccable@latest detect \`), and leave it running rather than commented: a stage nobody runs proves nothing.`); } }, }); -// What this gate deliberately does not do: name the tools it expects to find. A list of blessed -// commands per ecosystem is the kind of allowance list that rots, and it would turn every new -// language into a change here. So the mechanical half is "the placeholders were dealt with", -// and proving the wired gates actually bite stays where `stack` section 3 already puts it: -// introduce a violation, watch the gate fail, revert. +// What this gate deliberately does not do: name the tools it expects to find per ecosystem. A +// list of blessed commands per language is the kind of allowance list that rots, and it would +// turn every new language into a change here. So the stack half is "the placeholders were dealt +// with", and proving the wired gates actually bite stays where `stack` section 3 already puts +// it: introduce a violation, watch the gate fail, revert. +// +// The design half names one tool, because there is one: the project chose impeccable as its +// design method (decision 0020), the same way it chose a stack. What it still does not name is +// which surfaces to scan or which flags to pass, so a project can widen or narrow its own scan +// without touching this file. diff --git a/checks/check-stack.test.mjs b/checks/check-stack.test.mjs index d380c97..b3e3f9a 100644 --- a/checks/check-stack.test.mjs +++ b/checks/check-stack.test.mjs @@ -64,4 +64,43 @@ expectClean('stack-gates-armed', (fx) => { // report to make. A project on GitLab must not be failed here for not being on GitHub. expectClean('stack-gates-another-ci-host', stack); +// --- The design half: the detector counts as wired only when a workflow runs it ------------- + +const DESIGN_CONFIG = JSON.stringify({ detector: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] } }); +const design = ({ put }) => put('.impeccable/config.json', DESIGN_CONFIG); + +const detectStep = ' - name: Design detector\n run: npx -y impeccable@latest detect index.html\n'; + +// A project that judges its interface with the design method, and a CI run that never looks at +// what that interface renders. This is the window the design half exists for. +expectFail('stack-gates', (fx) => { + design(fx); + fx.put('.github/workflows/ci.yml', ARMED_CI); +}); + +// The evidence rule, stated as a test: a stage that is only talked about is not a stage. Without +// this one the gate would accept the placeholder it was written to refuse. +expectFail('stack-gates', (fx) => { + design(fx); + fx.put('.github/workflows/ci.yml', `${ARMED_CI} # - name: Design detector\n # run: npx impeccable detect index.html\n`); +}); + +// Wired for real, and the gate goes quiet. +expectClean('stack-gates-detector-wired', (fx) => { + design(fx); + fx.put('.github/workflows/ci.yml', ARMED_CI + detectStep); +}); + +// A copy that has not stood up the design method has no config to declare one, and must not be +// failed for missing a scan of an interface it does not have. +expectClean('stack-gates-quiet-without-the-design-method', ({ put }) => + put('.github/workflows/ci.yml', ARMED_CI)); + +// The two halves are independent: a stack file is not what arms the design half, and a wired +// detector does not excuse a workflow whose stack gates are still commented out. +expectClean('stack-gates-detector-alone-needs-no-stack-file', (fx) => { + design(fx); + fx.put('.github/workflows/ci.yml', PLACEHOLDER_CI + detectStep); +}); + report('stack-gate'); diff --git a/checks/design-method.mjs b/checks/design-method.mjs index 501f003..9ad26ad 100644 --- a/checks/design-method.mjs +++ b/checks/design-method.mjs @@ -7,8 +7,13 @@ // The install lands in .claude/skills, which is a symlink into .agents/skills here (decision // 0002), and upstream deliberately drops such a link so each harness gets its own build. So the // route installs the Claude build, then puts the payload where our skills live and restores the -// link: the same files, reachable under both names, with the symlink gate still green. The -// detector hook is not wired here; `stack` owns that, beside the ecosystem's own gates. +// link: the same files, reachable under both names, with the symlink gate still green. +// +// The edit hook comes with the payload, because that is the half that has to be there while the +// code is being written; it is wired per machine in the gitignored .claude/settings.local.json, +// and its command is guarded so a clone without the payload is a no-op rather than an error. The +// other half, the detector as a CI stage, is `stack`'s to wire beside the ecosystem's own gates, +// where `stack-gates` can see whether a workflow really runs it. import { existsSync, readFileSync, renameSync, rmSync, symlinkSync, lstatSync, readdirSync, rmdirSync, @@ -108,7 +113,7 @@ export function install(root) { throw new Error(`Node ${process.versions.node} is below ${PACKAGE}'s requirement (${want.range}): upgrade Node first, nothing was written.`); } console.log(`Node ${process.versions.node} meets ${PACKAGE} ${want.range || '(no stated range)'}. Installing...`); - run('npx', ['-y', `${PACKAGE}@latest`, 'install', '--providers=claude', '--scope=project', '--yes', '--no-hooks'], + run('npx', ['-y', `${PACKAGE}@latest`, 'install', '--providers=claude', '--scope=project', '--yes'], { cwd: root, stdio: ['ignore', 'inherit', 'inherit'] }); const what = adopt(root); const { version } = designMethod(root); diff --git a/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md b/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md index adf7b56..a39f57b 100644 --- a/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md +++ b/docs/specs/011-design-on-impeccable/tickets/05-detector-becomes-a-gate.md @@ -1,7 +1,7 @@ # 05: the first mechanical check on rendered quality - **Blocked by:** 01-install-route-and-declaration.md -- **Status:** ready +- **Status:** done - **Traces to:** BRIEF SC-8 **What to build:** A project with a frontend cannot ship a page full of the tells this framework @@ -10,14 +10,51 @@ and tests, and it runs on the developer's edits while the code is being written. **Acceptance:** -- [ ] `stack` wires the detector into the CI workflow it generates, for projects with a frontend, +- [x] `stack` wires the detector into the CI workflow it generates, for projects with a frontend, and says in `docs/standards/.md` what it checks and how a false positive is waived. -- [ ] The edit hook is installed with the payload and reports findings while building. -- [ ] `stack-gates` counts the detector as wired only when the CI workflow actually runs it, the +- [x] The edit hook is installed with the payload and reports findings while building. +- [x] `stack-gates` counts the detector as wired only when the CI workflow actually runs it, the same evidence rule the existing stack gates use, proven by a fixture in both directions. -- [ ] A page carrying a known tell fails the CI job; the same page with the tell removed passes. +- [x] A page carrying a known tell fails the CI job; the same page with the tell removed passes. Exercised for real on this repo's own explainer page in a scratch branch, not reasoned about. -- [ ] Waivers are visible: a rule ignored for a file states its reason in the config, which is what +- [x] Waivers are visible: a rule ignored for a file states its reason in the config, which is what impeccable's own ignore mechanism records. -- [ ] The detector's own dependency failures fail the job loudly; nothing skips to green. -- [ ] `node checks/check.mjs` and the self-test suites stay green. +- [x] The detector's own dependency failures fail the job loudly; nothing skips to green. +- [x] `node checks/check.mjs` and the self-test suites stay green. + +**What the first mechanical look at a rendered page found:** + +- **Proven on real CI, both directions.** A gradient headline on `index.html` turned the `design` + job red (run 31212639013 on a throwaway branch, deleted after); the page without it is green + (run 31213510003). The `gate` and `drill` jobs stayed green through both, so the new job is what + spoke. +- **The published CLI is stricter than the payload's own detector, by a lot.** Same file, same + cwd, same package version: `npx impeccable@latest detect index.html` reported nine findings + where the bundled `scripts/detect.mjs` reported none. Both engines were called directly to be + sure it was neither project config nor the design-system context. So CI runs the published CLI, + and `stack` says to reproduce a CI finding with the CI command, because the hook's silence is + not the gate's verdict. +- **Three of the nine were real, and the page was fixed rather than waived.** A tracked-caps pill + above the h1 (which also carried the all-caps finding), a heading jumping h2 to h4 with no h3 in + between, and four declarations at 11.5px, below the 12px floor for body text. The owner chose + the eyebrow fix; the other two are plain defects. +- **Four are waived, each scoped to the one file with its reason on the entry.** Three are + `cramped-padding` on the stat strip, a measured false positive: the cells whose `padding-left` + is zero are exactly the cells whose `border-left` is zero, and the static engine merges those + selectors into one element flush against a border it does not have. A five-line fixture + reproduces it, and removing the pairing clears it. The fourth is `flat-type-hierarchy`, waived + on the owner's call with the measurement in the reason: the rule wants about 1.25x between every + step, a 12.5/14/16 fixture still fires, and a two-step ramp would flatten this page's labels, + chips and captions into body text. Two more waivers predate these, from the same run: Inter as + the body face and the tool marquee, both confirmed by the owner. +- **The gate half needed a signal that survives a fresh clone.** The payload is gitignored, so + "does this project have an interface" cannot be answered from disk. `.impeccable/config.json`, + which the method writes and this repo tracks on purpose, is the declaration `stack-gates` reads. + It was watched failing on this repo by commenting the stage out, not only in fixtures. +- **The runner is the design method's own floor, not the gates'.** The `design` job sets Node 22 + because impeccable's `engines.node` asks for 22.12 or newer; the jobs beside it stay on 20, so + which Node the gates target (intake row 50) is still open and this ticket did not decide it. +- **Not done here:** `docs/design/reference/ui-library-showcase.html` stays out of the scan. Each + of its pages mimics one UI library's look on purpose, so its two findings are the file doing its + job. The cockpit is not scanned either: it renders on demand from `checks/cockpit-page.mjs` and + is a local stand rather than a published surface. diff --git a/index.html b/index.html index 7bd4982..f040495 100644 --- a/index.html +++ b/index.html @@ -109,13 +109,12 @@ .wrap{max-width:var(--maxw);margin:0 auto;padding-left:var(--pad);padding-right:var(--pad)} section{padding-top:104px;padding-bottom:104px} @media(max-width:640px){section{padding-top:72px;padding-bottom:72px}:root{--pad:20px} - .eyebrow{font-size:11px;letter-spacing:.09em;line-height:1.5} - /* on a narrow screen the pill wraps and competes with the h1: keep it a plain micro-label */ - .hero .eyebrow{border:0;background:none;padding:0;border-radius:0}} + .eyebrow{font-size:13px}} - .eyebrow{display:inline-flex;align-items:center;gap:9px;font-size:12px;letter-spacing:.14em; - line-height:1;text-transform:uppercase;color:var(--ink2);border:1px solid var(--line); - background:var(--surface);padding:9px 15px 8px;border-radius:999px} + /* A tracked-caps pill above the headline is the tell every AI landing page shares, and the + design method's detector names it. Same words, said quietly: sentence case, no pill. */ + .eyebrow{display:inline-flex;align-items:center;gap:9px;font-size:14px; + line-height:1.4;color:var(--ink2)} .eyebrow svg{color:var(--accent);width:14px;height:14px;transform:translateY(-0.5px)} .sec-head{max-width:64ch;margin-bottom:48px} h2{font-family:var(--font-display);font-size:clamp(27px,4vw,40px);line-height:1.14; @@ -160,7 +159,7 @@ .rulebook .rb-bar{display:flex;align-items:center;justify-content:space-between;gap:12px; padding:12px 18px;border-bottom:1px solid var(--line);background:var(--surface2)} .rulebook .rb-bar strong{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;font-weight:600} - .rulebook .rb-bar span{font-size:11.5px;color:var(--muted)} + .rulebook .rb-bar span{font-size:12.5px;color:var(--muted)} .rulebook pre{margin:0;padding:20px 18px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12.5px;line-height:1.75;color:var(--ink2);white-space:pre-wrap;overflow-x:auto} .rulebook pre b{color:var(--ink);font-weight:600} @@ -362,8 +361,8 @@ transition:opacity .25s ease,transform .25s cubic-bezier(.2,.7,.2,1),visibility .25s} .sheet.open{opacity:1;transform:none;pointer-events:auto;visibility:visible} .sheet .shead{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px} - .sheet .shead h4{font-size:15px;font-weight:700;letter-spacing:-.01em} - .sheet .grouplabel{display:flex;align-items:center;gap:8px;font-size:11.5px;letter-spacing:.1em; + .sheet .shead h3{font-size:15px;font-weight:700;letter-spacing:-.01em} + .sheet .grouplabel{display:flex;align-items:center;gap:8px;font-size:12.5px;letter-spacing:.1em; text-transform:uppercase;color:var(--muted);margin:18px 0 12px} .sheet .grouplabel svg{width:13px;height:13px} .swatches{display:grid;grid-template-columns:repeat(6,1fr);gap:10px} @@ -371,7 +370,7 @@ .swatch .dot{width:32px;height:32px;border-radius:50%;border:2px solid transparent;transition:transform .18s,border-color .18s} .swatch[aria-checked="true"] .dot{border-color:var(--ink);transform:scale(1.12)} .swatch:hover .dot{transform:scale(1.12)} - .swatch .nm{font-size:11.5px;color:var(--muted)} + .swatch .nm{font-size:12.5px;color:var(--muted)} .fontlist{display:grid;gap:8px} .fontopt{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%; padding:11px 14px;border-radius:10px;border:1px solid var(--line);background:var(--surface); @@ -380,7 +379,7 @@ .fontopt[aria-checked="true"]{border-color:var(--accent);color:var(--ink)} .fontopt .ag{font-size:16px;color:var(--ink2)} .fontopt[aria-checked="true"] .ag{color:var(--accent)} - .sheet .hint{font-size:11.5px;color:var(--muted);margin-top:18px} + .sheet .hint{font-size:12.5px;color:var(--muted);margin-top:18px} footer{padding-top:88px;padding-bottom:120px;border-top:1px solid var(--line);text-align:center;color:var(--muted)} footer .logo{margin-bottom:24px} @@ -629,7 +628,7 @@

Versions, and taking later improvements